Repository: steipete/CodexBar Branch: main Commit: ac17d7df9754 Files: 604 Total size: 4.2 MB Directory structure: gitextract_cvacokmp/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ ├── release-cli.yml │ └── upstream-monitor.yml ├── .gitignore ├── .swiftformat ├── .swiftlint.yml ├── .swiftpm/ │ └── xcode/ │ └── package.xcworkspace/ │ └── contents.xcworkspacedata ├── AGENTS.md ├── CHANGELOG.md ├── FORK_STATUS.md ├── IMPLEMENTATION_SUMMARY.md ├── Icon.icns ├── Icon.icon/ │ └── icon.json ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Scripts/ │ ├── analyze_quotio.sh │ ├── build_icon.sh │ ├── changelog-to-html.sh │ ├── check-release-assets.sh │ ├── check_upstreams.sh │ ├── compile_and_run.sh │ ├── docs-list.mjs │ ├── install_lint_tools.sh │ ├── launch.sh │ ├── lint.sh │ ├── make_appcast.sh │ ├── package_app.sh │ ├── prepare_upstream_pr.sh │ ├── release.sh │ ├── review_upstream.sh │ ├── setup_dev_signing.sh │ ├── sign-and-notarize.sh │ ├── test_live_update.sh │ ├── validate_changelog.sh │ └── verify_appcast.sh ├── Sources/ │ ├── CodexBar/ │ │ ├── About.swift │ │ ├── AppNotifications.swift │ │ ├── ClaudeLoginRunner.swift │ │ ├── CodexLoginRunner.swift │ │ ├── CodexbarApp.swift │ │ ├── Config/ │ │ │ └── CodexBarConfigMigrator.swift │ │ ├── CookieHeaderStore.swift │ │ ├── CopilotTokenStore.swift │ │ ├── CostHistoryChartMenuView.swift │ │ ├── CreditsHistoryChartMenuView.swift │ │ ├── CursorLoginRunner.swift │ │ ├── Date+RelativeDescription.swift │ │ ├── DisplayLink.swift │ │ ├── GeminiLoginRunner.swift │ │ ├── HiddenWindowView.swift │ │ ├── HistoricalUsagePace.swift │ │ ├── IconRenderer.swift │ │ ├── IconView.swift │ │ ├── InstallOrigin.swift │ │ ├── KeyboardShortcuts+Names.swift │ │ ├── KeychainMigration.swift │ │ ├── KeychainPromptCoordinator.swift │ │ ├── KimiK2TokenStore.swift │ │ ├── KimiTokenStore.swift │ │ ├── LaunchAtLoginManager.swift │ │ ├── LoadingPattern.swift │ │ ├── MenuBarDisplayMode.swift │ │ ├── MenuBarDisplayText.swift │ │ ├── MenuCardView.swift │ │ ├── MenuContent.swift │ │ ├── MenuDescriptor.swift │ │ ├── MenuHighlightStyle.swift │ │ ├── MiniMaxAPITokenStore.swift │ │ ├── MiniMaxCookieStore.swift │ │ ├── MouseLocationReader.swift │ │ ├── Notifications+CodexBar.swift │ │ ├── OpenAICreditsPurchaseWindowController.swift │ │ ├── PersonalInfoRedactor.swift │ │ ├── PreferencesAboutPane.swift │ │ ├── PreferencesAdvancedPane.swift │ │ ├── PreferencesComponents.swift │ │ ├── PreferencesDebugPane.swift │ │ ├── PreferencesDisplayPane.swift │ │ ├── PreferencesGeneralPane.swift │ │ ├── PreferencesProviderDetailView.swift │ │ ├── PreferencesProviderErrorView.swift │ │ ├── PreferencesProviderSettingsMetrics.swift │ │ ├── PreferencesProviderSettingsRows.swift │ │ ├── PreferencesProviderSidebarView.swift │ │ ├── PreferencesProvidersPane+Testing.swift │ │ ├── PreferencesProvidersPane.swift │ │ ├── PreferencesSelection.swift │ │ ├── PreferencesView.swift │ │ ├── ProviderBrandIcon.swift │ │ ├── ProviderRegistry.swift │ │ ├── ProviderSwitcherButtons.swift │ │ ├── ProviderToggleStore.swift │ │ ├── Providers/ │ │ │ ├── Alibaba/ │ │ │ │ ├── AlibabaCodingPlanProviderImplementation.swift │ │ │ │ └── AlibabaCodingPlanSettingsStore.swift │ │ │ ├── Amp/ │ │ │ │ ├── AmpProviderImplementation.swift │ │ │ │ └── AmpSettingsStore.swift │ │ │ ├── Antigravity/ │ │ │ │ ├── AntigravityLoginFlow.swift │ │ │ │ └── AntigravityProviderImplementation.swift │ │ │ ├── Augment/ │ │ │ │ ├── AugmentProviderImplementation.swift │ │ │ │ ├── AugmentProviderRuntime.swift │ │ │ │ └── AugmentSettingsStore.swift │ │ │ ├── Claude/ │ │ │ │ ├── ClaudeLoginFlow.swift │ │ │ │ ├── ClaudeProviderImplementation.swift │ │ │ │ └── ClaudeSettingsStore.swift │ │ │ ├── Codex/ │ │ │ │ ├── CodexLoginFlow.swift │ │ │ │ ├── CodexProviderImplementation.swift │ │ │ │ ├── CodexProviderRuntime.swift │ │ │ │ └── CodexSettingsStore.swift │ │ │ ├── Copilot/ │ │ │ │ ├── CopilotLoginFlow.swift │ │ │ │ ├── CopilotProviderImplementation.swift │ │ │ │ └── CopilotSettingsStore.swift │ │ │ ├── Cursor/ │ │ │ │ ├── CursorLoginFlow.swift │ │ │ │ ├── CursorProviderImplementation.swift │ │ │ │ └── CursorSettingsStore.swift │ │ │ ├── Factory/ │ │ │ │ ├── FactoryLoginFlow.swift │ │ │ │ ├── FactoryProviderImplementation.swift │ │ │ │ └── FactorySettingsStore.swift │ │ │ ├── Gemini/ │ │ │ │ ├── GeminiLoginFlow.swift │ │ │ │ └── GeminiProviderImplementation.swift │ │ │ ├── JetBrains/ │ │ │ │ ├── JetBrainsLoginFlow.swift │ │ │ │ ├── JetBrainsProviderImplementation.swift │ │ │ │ └── JetBrainsSettingsStore.swift │ │ │ ├── Kilo/ │ │ │ │ ├── KiloProviderImplementation.swift │ │ │ │ └── KiloSettingsStore.swift │ │ │ ├── Kimi/ │ │ │ │ ├── KimiProviderImplementation.swift │ │ │ │ └── KimiSettingsStore.swift │ │ │ ├── KimiK2/ │ │ │ │ ├── KimiK2ProviderImplementation.swift │ │ │ │ └── KimiK2SettingsStore.swift │ │ │ ├── Kiro/ │ │ │ │ └── KiroProviderImplementation.swift │ │ │ ├── MiniMax/ │ │ │ │ ├── MiniMaxProviderImplementation.swift │ │ │ │ └── MiniMaxSettingsStore.swift │ │ │ ├── Ollama/ │ │ │ │ ├── OllamaProviderImplementation.swift │ │ │ │ └── OllamaSettingsStore.swift │ │ │ ├── OpenCode/ │ │ │ │ ├── OpenCodeProviderImplementation.swift │ │ │ │ └── OpenCodeSettingsStore.swift │ │ │ ├── OpenRouter/ │ │ │ │ ├── OpenRouterProviderImplementation.swift │ │ │ │ └── OpenRouterSettingsStore.swift │ │ │ ├── Shared/ │ │ │ │ ├── ProviderCatalog.swift │ │ │ │ ├── ProviderContext.swift │ │ │ │ ├── ProviderCookieSourceUI.swift │ │ │ │ ├── ProviderImplementation.swift │ │ │ │ ├── ProviderImplementationRegistry.swift │ │ │ │ ├── ProviderLoginFlow.swift │ │ │ │ ├── ProviderMenuContext.swift │ │ │ │ ├── ProviderPresentation.swift │ │ │ │ ├── ProviderRuntime.swift │ │ │ │ ├── ProviderSettingsDescriptors.swift │ │ │ │ ├── ProviderTokenAccountSelection.swift │ │ │ │ └── SystemSettingsLinks.swift │ │ │ ├── Synthetic/ │ │ │ │ ├── SyntheticProviderImplementation.swift │ │ │ │ └── SyntheticSettingsStore.swift │ │ │ ├── VertexAI/ │ │ │ │ ├── VertexAILoginFlow.swift │ │ │ │ └── VertexAIProviderImplementation.swift │ │ │ ├── Warp/ │ │ │ │ ├── WarpProviderImplementation.swift │ │ │ │ └── WarpSettingsStore.swift │ │ │ └── Zai/ │ │ │ ├── ZaiProviderImplementation.swift │ │ │ └── ZaiSettingsStore.swift │ │ ├── Resources/ │ │ │ └── Icon-classic.icns │ │ ├── SessionQuotaNotifications.swift │ │ ├── SettingsStore+Config.swift │ │ ├── SettingsStore+ConfigPersistence.swift │ │ ├── SettingsStore+Defaults.swift │ │ ├── SettingsStore+MenuObservation.swift │ │ ├── SettingsStore+MenuPreferences.swift │ │ ├── SettingsStore+ProviderDetection.swift │ │ ├── SettingsStore+TokenAccounts.swift │ │ ├── SettingsStore+TokenCost.swift │ │ ├── SettingsStore.swift │ │ ├── SettingsStoreState.swift │ │ ├── StatusItemController+Actions.swift │ │ ├── StatusItemController+Animation.swift │ │ ├── StatusItemController+Menu.swift │ │ ├── StatusItemController+SwitcherViews.swift │ │ ├── StatusItemController.swift │ │ ├── SyntheticTokenStore.swift │ │ ├── UpdateChannel.swift │ │ ├── UsageBreakdownChartMenuView.swift │ │ ├── UsagePaceText.swift │ │ ├── UsageProgressBar.swift │ │ ├── UsageStore+Accessors.swift │ │ ├── UsageStore+ClaudeDebug.swift │ │ ├── UsageStore+HighestUsage.swift │ │ ├── UsageStore+HistoricalPace.swift │ │ ├── UsageStore+Logging.swift │ │ ├── UsageStore+OpenAIWeb.swift │ │ ├── UsageStore+Refresh.swift │ │ ├── UsageStore+Status.swift │ │ ├── UsageStore+Timeout.swift │ │ ├── UsageStore+TokenAccounts.swift │ │ ├── UsageStore+TokenCost.swift │ │ ├── UsageStore+WidgetSnapshot.swift │ │ ├── UsageStore.swift │ │ ├── UsageStoreSupport.swift │ │ └── ZaiTokenStore.swift │ ├── CodexBarCLI/ │ │ ├── CLIConfigCommand.swift │ │ ├── CLICostCommand.swift │ │ ├── CLIEntry.swift │ │ ├── CLIErrorReporting.swift │ │ ├── CLIExitCode.swift │ │ ├── CLIHelp.swift │ │ ├── CLIHelpers.swift │ │ ├── CLIIO.swift │ │ ├── CLIOptions.swift │ │ ├── CLIOutputPreferences.swift │ │ ├── CLIPayloads.swift │ │ ├── CLIRenderer.swift │ │ ├── CLIUsageCommand.swift │ │ └── TokenAccountCLI.swift │ ├── CodexBarClaudeWatchdog/ │ │ └── main.swift │ ├── CodexBarClaudeWebProbe/ │ │ └── main.swift │ ├── CodexBarCore/ │ │ ├── BrowserCookieAccessGate.swift │ │ ├── BrowserCookieImportOrder.swift │ │ ├── BrowserDetection.swift │ │ ├── Config/ │ │ │ ├── CodexBarConfig.swift │ │ │ ├── CodexBarConfigStore.swift │ │ │ ├── CodexBarConfigValidation.swift │ │ │ └── ProviderConfigEnvironment.swift │ │ ├── CookieHeaderCache.swift │ │ ├── CookieHeaderNormalizer.swift │ │ ├── CopilotUsageModels.swift │ │ ├── CostUsageFetcher.swift │ │ ├── CostUsageModels.swift │ │ ├── CreditsModels.swift │ │ ├── Double+Clamped.swift │ │ ├── Host/ │ │ │ ├── PTY/ │ │ │ │ └── TTYCommandRunner.swift │ │ │ └── Process/ │ │ │ └── SubprocessRunner.swift │ │ ├── KeychainAccessGate.swift │ │ ├── KeychainAccessPreflight.swift │ │ ├── KeychainCacheStore.swift │ │ ├── KeychainNoUIQuery.swift │ │ ├── Logging/ │ │ │ ├── CodexBarLog.swift │ │ │ ├── CompositeLogHandler.swift │ │ │ ├── FileLogHandler.swift │ │ │ ├── JSONStderrLogHandler.swift │ │ │ ├── LogCategories.swift │ │ │ ├── LogMetadata.swift │ │ │ ├── LogRedactor.swift │ │ │ ├── OSLogLogHandler.swift │ │ │ └── ProviderLogging.swift │ │ ├── OpenAIDashboardModels.swift │ │ ├── OpenAIWeb/ │ │ │ ├── OpenAIDashboardBrowserCookieImporter.swift │ │ │ ├── OpenAIDashboardFetcher.swift │ │ │ ├── OpenAIDashboardNavigationDelegate.swift │ │ │ ├── OpenAIDashboardParser.swift │ │ │ ├── OpenAIDashboardScrapeScript.swift │ │ │ ├── OpenAIDashboardWebViewCache.swift │ │ │ └── OpenAIDashboardWebsiteDataStore.swift │ │ ├── PathEnvironment.swift │ │ ├── ProviderCostSnapshot.swift │ │ ├── Providers/ │ │ │ ├── Alibaba/ │ │ │ │ ├── AlibabaCodingPlanAPIRegion.swift │ │ │ │ ├── AlibabaCodingPlanCookieImporter.swift │ │ │ │ ├── AlibabaCodingPlanProviderDescriptor.swift │ │ │ │ ├── AlibabaCodingPlanSettingsReader.swift │ │ │ │ ├── AlibabaCodingPlanUsageFetcher.swift │ │ │ │ └── AlibabaCodingPlanUsageSnapshot.swift │ │ │ ├── Amp/ │ │ │ │ ├── AmpProviderDescriptor.swift │ │ │ │ ├── AmpUsageFetcher.swift │ │ │ │ ├── AmpUsageParser.swift │ │ │ │ └── AmpUsageSnapshot.swift │ │ │ ├── Antigravity/ │ │ │ │ ├── AntigravityProviderDescriptor.swift │ │ │ │ └── AntigravityStatusProbe.swift │ │ │ ├── Augment/ │ │ │ │ ├── AuggieCLIProbe.swift │ │ │ │ ├── AugmentProviderDescriptor.swift │ │ │ │ ├── AugmentSessionKeepalive.swift │ │ │ │ └── AugmentStatusProbe.swift │ │ │ ├── CLIProbeSessionResetter.swift │ │ │ ├── Claude/ │ │ │ │ ├── ClaudeCLISession.swift │ │ │ │ ├── ClaudeCredentialRouting.swift │ │ │ │ ├── ClaudeOAuth/ │ │ │ │ │ ├── ClaudeOAuthCredentialModels.swift │ │ │ │ │ ├── ClaudeOAuthCredentials+Hashing.swift │ │ │ │ │ ├── ClaudeOAuthCredentials+SecurityCLIReader.swift │ │ │ │ │ ├── ClaudeOAuthCredentials+TestingOverrides.swift │ │ │ │ │ ├── ClaudeOAuthCredentials.swift │ │ │ │ │ ├── ClaudeOAuthDelegatedRefreshCoordinator.swift │ │ │ │ │ ├── ClaudeOAuthKeychainAccessGate.swift │ │ │ │ │ ├── ClaudeOAuthKeychainPromptMode.swift │ │ │ │ │ ├── ClaudeOAuthKeychainQueryTiming.swift │ │ │ │ │ ├── ClaudeOAuthKeychainReadStrategy.swift │ │ │ │ │ ├── ClaudeOAuthMutableKeychainOverrides.swift │ │ │ │ │ ├── ClaudeOAuthRefreshFailureGate.swift │ │ │ │ │ └── ClaudeOAuthUsageFetcher.swift │ │ │ │ ├── ClaudePlan.swift │ │ │ │ ├── ClaudeProviderDescriptor.swift │ │ │ │ ├── ClaudeSourcePlanner.swift │ │ │ │ ├── ClaudeStatusProbe.swift │ │ │ │ ├── ClaudeUsageDataSource.swift │ │ │ │ ├── ClaudeUsageFetcher.swift │ │ │ │ └── ClaudeWeb/ │ │ │ │ └── ClaudeWebAPIFetcher.swift │ │ │ ├── Codex/ │ │ │ │ ├── CodexCLISession.swift │ │ │ │ ├── CodexOAuth/ │ │ │ │ │ ├── CodexOAuthCredentials.swift │ │ │ │ │ ├── CodexOAuthUsageFetcher.swift │ │ │ │ │ └── CodexTokenRefresher.swift │ │ │ │ ├── CodexProviderDescriptor.swift │ │ │ │ ├── CodexStatusProbe.swift │ │ │ │ ├── CodexUsageDataSource.swift │ │ │ │ └── CodexWebDashboardStrategy.swift │ │ │ ├── Copilot/ │ │ │ │ ├── CopilotDeviceFlow.swift │ │ │ │ ├── CopilotProviderDescriptor.swift │ │ │ │ └── CopilotUsageFetcher.swift │ │ │ ├── Cursor/ │ │ │ │ ├── CursorProviderDescriptor.swift │ │ │ │ ├── CursorRequestUsage.swift │ │ │ │ └── CursorStatusProbe.swift │ │ │ ├── Factory/ │ │ │ │ ├── FactoryLocalStorageImporter.swift │ │ │ │ ├── FactoryProviderDescriptor.swift │ │ │ │ └── FactoryStatusProbe.swift │ │ │ ├── Gemini/ │ │ │ │ ├── GeminiProviderDescriptor.swift │ │ │ │ └── GeminiStatusProbe.swift │ │ │ ├── JetBrains/ │ │ │ │ ├── JetBrainsIDEDetector.swift │ │ │ │ ├── JetBrainsProviderDescriptor.swift │ │ │ │ └── JetBrainsStatusProbe.swift │ │ │ ├── Kilo/ │ │ │ │ ├── KiloProviderDescriptor.swift │ │ │ │ ├── KiloSettingsReader.swift │ │ │ │ ├── KiloUsageDataSource.swift │ │ │ │ └── KiloUsageFetcher.swift │ │ │ ├── Kimi/ │ │ │ │ ├── KimiAPIError.swift │ │ │ │ ├── KimiCookieHeader.swift │ │ │ │ ├── KimiCookieImporter.swift │ │ │ │ ├── KimiModels.swift │ │ │ │ ├── KimiProviderDescriptor.swift │ │ │ │ ├── KimiSettingsReader.swift │ │ │ │ ├── KimiUsageFetcher.swift │ │ │ │ └── KimiUsageSnapshot.swift │ │ │ ├── KimiK2/ │ │ │ │ ├── KimiK2ProviderDescriptor.swift │ │ │ │ ├── KimiK2SettingsReader.swift │ │ │ │ └── KimiK2UsageFetcher.swift │ │ │ ├── Kiro/ │ │ │ │ ├── KiroProviderDescriptor.swift │ │ │ │ └── KiroStatusProbe.swift │ │ │ ├── MiniMax/ │ │ │ │ ├── MiniMaxAPIRegion.swift │ │ │ │ ├── MiniMaxAPISettingsReader.swift │ │ │ │ ├── MiniMaxAuthMode.swift │ │ │ │ ├── MiniMaxCookieHeader.swift │ │ │ │ ├── MiniMaxCookieImporter.swift │ │ │ │ ├── MiniMaxLocalStorageImporter.swift │ │ │ │ ├── MiniMaxProviderDescriptor.swift │ │ │ │ ├── MiniMaxSettingsReader.swift │ │ │ │ ├── MiniMaxUsageFetcher.swift │ │ │ │ └── MiniMaxUsageSnapshot.swift │ │ │ ├── Ollama/ │ │ │ │ ├── OllamaProviderDescriptor.swift │ │ │ │ ├── OllamaUsageFetcher.swift │ │ │ │ ├── OllamaUsageParser.swift │ │ │ │ └── OllamaUsageSnapshot.swift │ │ │ ├── OpenCode/ │ │ │ │ ├── OpenCodeCookieImporter.swift │ │ │ │ ├── OpenCodeProviderDescriptor.swift │ │ │ │ ├── OpenCodeUsageFetcher.swift │ │ │ │ └── OpenCodeUsageSnapshot.swift │ │ │ ├── OpenRouter/ │ │ │ │ ├── OpenRouterProviderDescriptor.swift │ │ │ │ ├── OpenRouterSettingsReader.swift │ │ │ │ └── OpenRouterUsageStats.swift │ │ │ ├── ProviderBranding.swift │ │ │ ├── ProviderCLIConfig.swift │ │ │ ├── ProviderCandidateRetryRunner.swift │ │ │ ├── ProviderCookieSource.swift │ │ │ ├── ProviderDescriptor.swift │ │ │ ├── ProviderFetchPlan.swift │ │ │ ├── ProviderInteractionContext.swift │ │ │ ├── ProviderSettingsSnapshot.swift │ │ │ ├── ProviderTokenResolver.swift │ │ │ ├── ProviderVersionDetector.swift │ │ │ ├── Providers.swift │ │ │ ├── Synthetic/ │ │ │ │ ├── SyntheticProviderDescriptor.swift │ │ │ │ ├── SyntheticSettingsReader.swift │ │ │ │ └── SyntheticUsageStats.swift │ │ │ ├── VertexAI/ │ │ │ │ ├── VertexAIOAuth/ │ │ │ │ │ ├── VertexAIOAuthCredentials.swift │ │ │ │ │ ├── VertexAITokenRefresher.swift │ │ │ │ │ └── VertexAIUsageFetcher.swift │ │ │ │ └── VertexAIProviderDescriptor.swift │ │ │ ├── Warp/ │ │ │ │ ├── WarpProviderDescriptor.swift │ │ │ │ ├── WarpSettingsReader.swift │ │ │ │ └── WarpUsageFetcher.swift │ │ │ └── Zai/ │ │ │ ├── ZaiAPIRegion.swift │ │ │ ├── ZaiProviderDescriptor.swift │ │ │ ├── ZaiSettingsReader.swift │ │ │ └── ZaiUsageStats.swift │ │ ├── TextParsing.swift │ │ ├── TokenAccountSupport.swift │ │ ├── TokenAccountSupportCatalog+Data.swift │ │ ├── TokenAccounts.swift │ │ ├── UsageFetcher.swift │ │ ├── UsageFormatter.swift │ │ ├── UsagePace.swift │ │ ├── Vendored/ │ │ │ └── CostUsage/ │ │ │ ├── CostUsageCache.swift │ │ │ ├── CostUsageJsonl.swift │ │ │ ├── CostUsagePricing.swift │ │ │ ├── CostUsageScanner+Claude.swift │ │ │ ├── CostUsageScanner+Timestamp.swift │ │ │ └── CostUsageScanner.swift │ │ ├── WebKit/ │ │ │ └── WebKitTeardown.swift │ │ └── WidgetSnapshot.swift │ ├── CodexBarMacroSupport/ │ │ └── ProviderRegistrationMacros.swift │ ├── CodexBarMacros/ │ │ └── ProviderRegistrationMacros.swift │ └── CodexBarWidget/ │ ├── CodexBarWidgetBundle.swift │ ├── CodexBarWidgetProvider.swift │ └── CodexBarWidgetViews.swift ├── Tests/ │ └── CodexBarTests/ │ ├── AlibabaCodingPlanCookieImporterTests.swift │ ├── AlibabaCodingPlanProviderTests.swift │ ├── AmpUsageFetcherTests.swift │ ├── AmpUsageParserTests.swift │ ├── AntigravityStatusProbeTests.swift │ ├── AppDelegateTests.swift │ ├── AugmentCLIFetchStrategyFallbackTests.swift │ ├── AugmentStatusProbeTests.swift │ ├── BatteryDrainDiagnosticTests.swift │ ├── BrowserCookieOrderLabelTests.swift │ ├── BrowserDetectionTests.swift │ ├── CLIArgumentParsingTests.swift │ ├── CLICostTests.swift │ ├── CLIEntryTests.swift │ ├── CLIOutputTests.swift │ ├── CLIProviderSelectionTests.swift │ ├── CLISnapshotTests.swift │ ├── CLIWebFallbackTests.swift │ ├── ClaudeBaselineCharacterizationTests.swift │ ├── ClaudeCredentialRoutingTests.swift │ ├── ClaudeDebugDiagnosticsTests.swift │ ├── ClaudeOAuthCredentialsStorePromptPolicyTests.swift │ ├── ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift │ ├── ClaudeOAuthCredentialsStoreSecurityCLITests.swift │ ├── ClaudeOAuthCredentialsStoreTests.swift │ ├── ClaudeOAuthDelegatedRefreshCoordinatorTests.swift │ ├── ClaudeOAuthDelegatedRefreshRecoveryTests.swift │ ├── ClaudeOAuthFetchStrategyAvailabilityTests.swift │ ├── ClaudeOAuthKeychainAccessGateTests.swift │ ├── ClaudeOAuthRefreshDispositionTests.swift │ ├── ClaudeOAuthRefreshFailureGateTests.swift │ ├── ClaudeOAuthTests.swift │ ├── ClaudePlanResolverTests.swift │ ├── ClaudeResilienceTests.swift │ ├── ClaudeSourcePlannerTests.swift │ ├── ClaudeUsageDelegatedRefreshEnvironmentTests.swift │ ├── ClaudeUsageTests.swift │ ├── CodexBarWidgetProviderTests.swift │ ├── CodexOAuthTests.swift │ ├── CodexbarTests.swift │ ├── ConfigValidationTests.swift │ ├── CookieHeaderCacheTests.swift │ ├── CookieHeaderNormalizerTests.swift │ ├── CopilotUsageModelsTests.swift │ ├── CostUsageCacheTests.swift │ ├── CostUsageDecodingTests.swift │ ├── CostUsageJsonlPerformanceTests.swift │ ├── CostUsageJsonlScannerTests.swift │ ├── CostUsagePricingTests.swift │ ├── CostUsageScannerBreakdownTests.swift │ ├── CostUsageScannerTests.swift │ ├── CursorStatusProbeTests.swift │ ├── FactoryStatusProbeFetchTests.swift │ ├── FactoryStatusProbeTests.swift │ ├── GeminiAPITestHelpers.swift │ ├── GeminiLoginAlertTests.swift │ ├── GeminiMenuCardTests.swift │ ├── GeminiStatusProbeAPITests.swift │ ├── GeminiStatusProbePlanTests.swift │ ├── GeminiStatusProbeTests.swift │ ├── GeminiTestEnvironment.swift │ ├── GoogleWorkspaceStatusTests.swift │ ├── HistoricalUsagePaceTestSupport.swift │ ├── HistoricalUsagePaceTests.swift │ ├── InstallOriginTests.swift │ ├── JetBrainsIDEDetectorTests.swift │ ├── JetBrainsStatusProbeTests.swift │ ├── KeyboardShortcutsBundleTests.swift │ ├── KeychainCacheStoreTests.swift │ ├── KeychainMigrationTests.swift │ ├── KiloSettingsReaderTests.swift │ ├── KiloUsageFetcherTests.swift │ ├── KimiK2SettingsReaderTests.swift │ ├── KimiK2TokenStoreTestSupport.swift │ ├── KimiK2UsageFetcherTests.swift │ ├── KimiProviderTests.swift │ ├── KiroStatusProbeTests.swift │ ├── LiveAccountTests.swift │ ├── LoadingPatternTests.swift │ ├── MenuCardKiloPassTests.swift │ ├── MenuCardModelTests.swift │ ├── MenuDescriptorKiloTests.swift │ ├── MiniMaxAPITokenFetchTests.swift │ ├── MiniMaxLocalStorageImporterTests.swift │ ├── MiniMaxProviderTests.swift │ ├── OllamaUsageFetcherRetryMappingTests.swift │ ├── OllamaUsageFetcherTests.swift │ ├── OllamaUsageParserTests.swift │ ├── OpenAIDashboardBrowserCookieImporterTests.swift │ ├── OpenAIDashboardFetcherCreditsWaitTests.swift │ ├── OpenAIDashboardNavigationDelegateTests.swift │ ├── OpenAIDashboardOffscreenHostTests.swift │ ├── OpenAIDashboardParserTests.swift │ ├── OpenAIDashboardWebViewCacheTests.swift │ ├── OpenAIWebAccountSwitchTests.swift │ ├── OpenCodeUsageFetcherErrorTests.swift │ ├── OpenCodeUsageParserTests.swift │ ├── OpenRouterUsageStatsTests.swift │ ├── PathBuilderTests.swift │ ├── PreferencesPaneSmokeTests.swift │ ├── ProviderCandidateRetryRunnerTests.swift │ ├── ProviderConfigEnvironmentTests.swift │ ├── ProviderIconResourcesTests.swift │ ├── ProviderMetadataStatusLinkTests.swift │ ├── ProviderRegistryTests.swift │ ├── ProviderSettingsDescriptorTests.swift │ ├── ProviderToggleStoreTests.swift │ ├── ProviderTokenResolverTests.swift │ ├── ProviderVersionDetectorTests.swift │ ├── ProvidersPaneCoverageTests.swift │ ├── SessionQuotaNotificationLogicTests.swift │ ├── SettingsStoreAdditionalTests.swift │ ├── SettingsStoreCoverageTests.swift │ ├── SettingsStoreTests.swift │ ├── StatusItemAnimationTests.swift │ ├── StatusItemControllerMenuTests.swift │ ├── StatusMenuTests.swift │ ├── StatusProbeTests.swift │ ├── SubprocessRunnerTests.swift │ ├── SubscriptionDetectionTests.swift │ ├── SyntheticProviderTests.swift │ ├── TTYCommandRunnerTests.swift │ ├── TTYIntegrationTests.swift │ ├── TestProcessCleanup.swift │ ├── TestStores.swift │ ├── TextParsingTests.swift │ ├── TokenAccountEnvironmentPrecedenceTests.swift │ ├── TokenAccountStoreTests.swift │ ├── UpdateChannelTests.swift │ ├── UsageFormatterTests.swift │ ├── UsagePaceTests.swift │ ├── UsagePaceTextTests.swift │ ├── UsageStoreCoverageTests.swift │ ├── UsageStoreHighestUsageTests.swift │ ├── UsageStorePathDebugTests.swift │ ├── UsageStoreSessionQuotaTransitionTests.swift │ ├── WarpUsageFetcherTests.swift │ ├── WebKitTeardownTests.swift │ ├── WidgetSnapshotTests.swift │ ├── ZaiAvailabilityTests.swift │ ├── ZaiProviderTests.swift │ └── ZaiTokenStoreTestSupport.swift ├── TestsLinux/ │ ├── JetBrainsParserLinuxTests.swift │ └── PlatformGatingTests.swift ├── appcast.xml ├── bin/ │ ├── docs-list │ └── install-codexbar-cli.sh ├── docs/ │ ├── .nojekyll │ ├── CNAME │ ├── DEVELOPMENT.md │ ├── DEVELOPMENT_SETUP.md │ ├── FORK_QUICK_START.md │ ├── FORK_ROADMAP.md │ ├── FORK_SETUP.md │ ├── KEYCHAIN_FIX.md │ ├── QUOTIO_ANALYSIS.md │ ├── RELEASING.md │ ├── TODO.md │ ├── UPSTREAM_STRATEGY.md │ ├── alibaba-coding-plan.md │ ├── amp.md │ ├── antigravity.md │ ├── architecture.md │ ├── augment.md │ ├── claude-comparison-since-0.18.0beta2.md │ ├── claude.md │ ├── cli.md │ ├── codex-oauth.md │ ├── codex.md │ ├── configuration.md │ ├── copilot.md │ ├── cursor.md │ ├── factory.md │ ├── gemini.md │ ├── icon.md │ ├── index.html │ ├── jetbrains.md │ ├── kilo.md │ ├── kimi-k2.md │ ├── kimi.md │ ├── kiro.md │ ├── minimax.md │ ├── ollama.md │ ├── opencode.md │ ├── openrouter.md │ ├── packaging.md │ ├── perf-energy-issue-139-main-fix-validation-2026-02-19.md │ ├── perf-energy-issue-139-simulation-report-2026-02-19.md │ ├── provider.md │ ├── providers.md │ ├── quotio-comparison.md │ ├── refactor/ │ │ ├── claude-current-baseline.md │ │ ├── claude-provider-vnext-locked.md │ │ ├── cli.md │ │ └── macros.md │ ├── refresh-loop.md │ ├── releasing-homebrew.md │ ├── session-keepalive-design.md │ ├── site.css │ ├── sparkle.md │ ├── status.md │ ├── ui.md │ ├── vertexai.md │ ├── warp.md │ ├── web-integration.md │ ├── webkit.md │ ├── widgets.md │ └── zai.md └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: ["*"] pull_request: jobs: lint-build-test: runs-on: macos-latest steps: - uses: actions/checkout@v6 - name: Select Xcode 26.1.1 (if present) or fallback to default run: | set -euo pipefail for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do if [[ -d "$candidate" ]]; then sudo xcode-select -s "${candidate}/Contents/Developer" echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" break fi done /usr/bin/xcodebuild -version - name: Swift toolchain version run: | set -euo pipefail swift --version swift package --version - name: Install lint tools run: ./Scripts/install_lint_tools.sh - name: Lint run: ./Scripts/lint.sh lint - name: Swift Test run: swift test --no-parallel build-linux-cli: strategy: fail-fast: false matrix: include: - name: linux-x64 runs-on: ubuntu-24.04 - name: linux-arm64 runs-on: ubuntu-24.04-arm runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v6 - name: Runner info run: | set -euo pipefail uname -a uname -m - name: Setup Swift 6.2.1 uses: swift-actions/setup-swift@v3 with: swift-version: "6.2.1" skip-verify-signature: true - name: Build CodexBarCLI (release, static Swift stdlib) run: swift build -c release --product CodexBarCLI --static-swift-stdlib - name: Swift Test (Linux only) run: swift test --parallel - name: Smoke test CodexBarCLI shell: bash run: | set -euo pipefail BIN_DIR="$(swift build -c release --product CodexBarCLI --static-swift-stdlib --show-bin-path)" BIN="$BIN_DIR/CodexBarCLI" "$BIN" --help >/dev/null "$BIN" --version >/dev/null if "$BIN" usage --provider codex --web >/dev/null 2>&1; then echo "Expected --web to fail on Linux" exit 1 fi "$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true grep -q "macOS" /tmp/codexbarcli-stderr.txt ================================================ FILE: .github/workflows/release-cli.yml ================================================ name: Release Linux CLI on: release: types: [published] workflow_dispatch: permissions: contents: write jobs: build-linux-cli: strategy: fail-fast: false matrix: include: - name: linux-x64 runs-on: ubuntu-24.04 - name: linux-arm64 runs-on: ubuntu-24.04-arm runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v6 - name: Runner info run: | set -euo pipefail uname -a uname -m - name: Setup Swift 6.2.1 uses: swift-actions/setup-swift@v3 with: swift-version: "6.2.1" skip-verify-signature: true - name: Build CodexBarCLI (release) run: swift build -c release --product CodexBarCLI --static-swift-stdlib - name: Package id: pkg shell: bash run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" if [[ -z "$TAG" ]]; then echo "Missing tag (GITHUB_REF_NAME)." >&2 exit 1 fi ARCH="$(uname -m)" case "$ARCH" in x86_64) ARCH="x86_64" ;; aarch64|arm64) ARCH="aarch64" ;; esac BIN_DIR="$(swift build -c release --product CodexBarCLI --static-swift-stdlib --show-bin-path)" OUT_DIR="$(mktemp -d)" install -m 0755 "$BIN_DIR/CodexBarCLI" "$OUT_DIR/CodexBarCLI" ln -s "CodexBarCLI" "$OUT_DIR/codexbar" ASSET="CodexBarCLI-${TAG}-linux-${ARCH}.tar.gz" (cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar) sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256" echo "out_dir=$OUT_DIR" >> "$GITHUB_OUTPUT" echo "asset=$ASSET" >> "$GITHUB_OUTPUT" - name: Upload release assets if: github.event_name == 'release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" OUT_DIR="${{ steps.pkg.outputs.out_dir }}" ASSET="${{ steps.pkg.outputs.asset }}" gh release upload "$TAG" "$OUT_DIR/$ASSET" "$OUT_DIR/$ASSET.sha256" --clobber - name: Upload workflow artifact (manual runs) if: github.event_name != 'release' uses: actions/upload-artifact@v6 with: name: codexbar-linux-cli-${{ matrix.name }} path: | ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }} ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256 ================================================ FILE: .github/workflows/upstream-monitor.yml ================================================ name: Monitor Upstream Changes on: schedule: # Run Monday and Thursday at 9 AM UTC - cron: '0 9 * * 1,4' workflow_dispatch: inputs: target: description: 'Which upstream to check' required: false default: 'all' type: choice options: - all - upstream - quotio jobs: check-upstreams: runs-on: ubuntu-latest permissions: issues: write contents: read steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - name: Configure git run: | git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' - name: Add upstream remotes run: | git remote add upstream https://github.com/steipete/CodexBar.git || true git remote add quotio https://github.com/nguyenphutrong/quotio.git || true git fetch upstream git fetch quotio - name: Check for new commits id: check run: | # Count new commits in upstream UPSTREAM_NEW=$(git log --oneline main..upstream/main --no-merges 2>/dev/null | wc -l | tr -d ' ') echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT # Count new commits in quotio (last 7 days) QUOTIO_NEW=$(git log --oneline --all --remotes=quotio/main --since="7 days ago" 2>/dev/null | wc -l | tr -d ' ') echo "quotio_commits=$QUOTIO_NEW" >> $GITHUB_OUTPUT # Get commit summaries echo "upstream_summary<> $GITHUB_OUTPUT git log --oneline main..upstream/main --no-merges 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT echo "quotio_summary<> $GITHUB_OUTPUT git log --oneline --remotes=quotio/main --since="7 days ago" 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create or update issue if: steps.check.outputs.upstream_commits > 0 || steps.check.outputs.quotio_commits > 0 uses: actions/github-script@v8 with: script: | const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}'; const quotioCommits = '${{ steps.check.outputs.quotio_commits }}'; const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`; const quotioSummary = `${{ steps.check.outputs.quotio_summary }}`; const body = `## 🔄 Upstream Changes Detected **steipete/CodexBar:** ${upstreamCommits} new commits **quotio:** ${quotioCommits} new commits (last 7 days) ### steipete/CodexBar Recent Commits \`\`\` ${upstreamSummary} \`\`\` ### quotio Recent Commits \`\`\` ${quotioSummary} \`\`\` ### 📋 Review Actions **Review upstream changes:** \`\`\`bash ./Scripts/review_upstream.sh upstream \`\`\` **Review quotio changes:** \`\`\`bash ./Scripts/analyze_quotio.sh \`\`\` **View detailed diffs:** \`\`\`bash git diff main..upstream/main git log -p quotio/main --since='7 days ago' \`\`\` ### 🔗 Links - [steipete commits](https://github.com/steipete/CodexBar/compare/${context.sha}...steipete:CodexBar:main) - [quotio commits](https://github.com/nguyenphutrong/quotio/commits/main) --- *Auto-generated by upstream-monitor workflow* *Last checked: ${new Date().toISOString()}*`; // Check for existing open issue const issues = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, labels: 'upstream-sync', state: 'open' }); if (issues.data.length > 0) { // Update existing issue await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issues.data[0].number, body: body }); // Add comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issues.data[0].number, body: `🔄 Updated with latest changes (${upstreamCommits} upstream, ${quotioCommits} quotio)` }); } else { // Create new issue await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: '🔄 Upstream Changes Available for Review', body: body, labels: ['upstream-sync', 'needs-review'] }); } - name: No changes detected if: steps.check.outputs.upstream_commits == 0 && steps.check.outputs.quotio_commits == 0 run: | echo "✅ No new upstream changes detected" echo "steipete/CodexBar: up to date" echo "quotio: no commits in last 7 days" ================================================ FILE: .gitignore ================================================ # Xcode user/session xcuserdata/ .swiftpm/xcode/xcshareddata/ .codexbar/config.json *.env *.local # Build products .build/ DerivedData # Bundles / artifacts # Main app bundle (any variation) CodexBar.app/ CodexBar *.app/ CodexBar_*.app/ Codexbar.app/ # Release artifacts *.ipa *.dSYM* *.zip *.delta *.dmg *.pkg *.tar.gz *.tgz Icon.iconset debug_*.swift # Misc .DS_Store .vscode/ .codex/environments/ .swiftpm-cache/ # Debug/analysis docs docs/*-analysis.md docs/.astro/ # Swift Package Manager metadata (leave sources tracked) # Packages/ # Package.resolved ================================================ FILE: .swiftformat ================================================ # SwiftFormat configuration for Peekaboo project # Compatible with Swift 6 strict concurrency mode # IMPORTANT: Don't remove self where it's required for Swift 6 concurrency --self insert # Insert self for member references (required for Swift 6) --selfrequired # List of functions that require explicit self --importgrouping testable-bottom # Group @testable imports at the bottom --extensionacl on-declarations # Set ACL on extension members # Indentation --indent 4 --indentcase false --ifdef no-indent --xcodeindentation enabled # Line breaks --linebreaks lf --maxwidth 120 # Whitespace --trimwhitespace always --emptybraces no-space --nospaceoperators ...,..< --ranges no-space --someAny true # Wrapping --wraparguments before-first --wrapparameters before-first --wrapcollections before-first --closingparen same-line # Organization --organizetypes class,struct,enum,extension --extensionmark "MARK: - %t + %p" --marktypes always --markextensions always --structthreshold 0 --enumthreshold 0 # Swift 6 specific --swiftversion 6.2 # Other --stripunusedargs closure-only --header ignore --allman false # Exclusions --exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift ================================================ FILE: .swiftlint.yml ================================================ # SwiftLint configuration for Peekaboo - Swift 6 compatible # Paths to include included: - Sources - Tests # Paths to exclude excluded: - .build - DerivedData - "**/Generated" - "**/Resources" - "**/.build" - "**/Package.swift" - "**/Tests/Resources" - "Apps/CLI/.build" - "**/DerivedData" - "**/.swiftpm" - Pods - Carthage - fastlane - vendor - "*.playground" # Exclude specific files that should not be linted/formatted - "Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift" # Analyzer rules (require compilation) analyzer_rules: - unused_declaration - unused_import # Enable specific rules opt_in_rules: - array_init - closure_spacing - contains_over_first_not_nil - empty_count - empty_string - explicit_init - fallthrough - fatal_error_message - first_where - joined_default_parameter - last_where - literal_expression_end_indentation - multiline_arguments - multiline_parameters - operator_usage_whitespace - overridden_super_call - pattern_matching_keywords - private_outlet - prohibited_super_call - redundant_nil_coalescing - sorted_first_last - switch_case_alignment - unneeded_parentheses_in_closure_argument - vertical_parameter_alignment_on_call # Disable rules that conflict with Swift 6 or our coding style disabled_rules: # Swift 6 requires explicit self - disable explicit_self rule - explicit_self # SwiftFormat handles these - trailing_whitespace - trailing_newline - trailing_comma - vertical_whitespace - indentation_width # Too restrictive or not applicable - identifier_name # Single letter names are fine in many contexts - file_header - explicit_top_level_acl - explicit_acl - explicit_type_interface - missing_docs - required_deinit - prefer_nimble - quick_discouraged_call - quick_discouraged_focused_test - quick_discouraged_pending_test - anonymous_argument_in_multiline_closure - no_extension_access_modifier - no_grouping_extension - switch_case_on_newline - strict_fileprivate - extension_access_modifier - convenience_type - no_magic_numbers - one_declaration_per_file - vertical_whitespace_between_cases - vertical_whitespace_closing_braces - superfluous_else - number_separator - prefixed_toplevel_constant - opening_brace - trailing_closure - contrasted_opening_brace - sorted_imports - redundant_type_annotation - shorthand_optional_binding - untyped_error_in_catch - file_name - todo # Rule configurations force_cast: warning force_try: warning # identifier_name rule disabled - see disabled_rules section type_name: min_length: warning: 2 error: 1 max_length: warning: 60 error: 80 function_body_length: warning: 150 error: 300 file_length: warning: 1500 error: 2500 ignore_comment_only_lines: true type_body_length: warning: 800 error: 1200 cyclomatic_complexity: warning: 20 error: 120 large_tuple: warning: 4 error: 5 nesting: type_level: warning: 4 error: 6 function_level: warning: 5 error: 7 line_length: warning: 120 error: 250 ignores_comments: true ignores_urls: true # Custom rules can be added here if needed # Reporter type reporter: "xcode" ================================================ FILE: .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Modules - `Sources/CodexBar`: Swift 6 menu bar app (usage/credits probes, icon renderer, settings). Keep changes small and reuse existing helpers. - `Tests/CodexBarTests`: XCTest coverage for usage parsing, status probes, icon patterns; mirror new logic with focused tests. - `Scripts`: build/package helpers (`package_app.sh`, `sign-and-notarize.sh`, `make_appcast.sh`, `build_icon.sh`, `compile_and_run.sh`). - `docs`: release notes and process (`docs/RELEASING.md`, screenshots). Root-level zips/appcast are generated artifacts—avoid editing except during releases. ## Build, Test, Run - Dev loop: `./Scripts/compile_and_run.sh` kills old instances, runs `swift build` + `swift test`, packages, relaunches `CodexBar.app`, and confirms it stays running. - Quick build/test: `swift build` (debug) or `swift build -c release`; `swift test` for the full XCTest suite. - Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`, then restart with `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. - Release flow: `./Scripts/sign-and-notarize.sh` (arm64 notarized zip) and `./Scripts/make_appcast.sh `; follow validation steps in `docs/RELEASING.md`. ## Coding Style & Naming - Enforce SwiftFormat/SwiftLint: run `swiftformat Sources Tests` and `swiftlint --strict`. 4-space indent, 120-char lines, explicit `self` is intentional—do not remove. - Favor small, typed structs/enums; maintain existing `MARK` organization. Use descriptive symbols; match current commit tone. ## Testing Guidelines - Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods). - Always run `swift test` (or `./Scripts/compile_and_run.sh`) before handoff; add fixtures for new parsing/formatting scenarios. - After any code change, run `pnpm check` and fix all reported format/lint issues before handoff. ## Commit & PR Guidelines - Commit messages: short imperative clauses (e.g., “Improve usage probe”, “Fix icon dimming”); keep commits scoped. - PRs/patches should list summary, commands run, screenshots/GIFs for UI changes, and linked issue/reference when relevant. ## Agent Notes - Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation. - Validate behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries. - To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. - After any code change that affects the app, always rebuild with `Scripts/package_app.sh` and restart the app using the command above before validating behavior. - If you edited code, run `scripts/compile_and_run.sh` before handoff; it kills old instances, builds, tests, packages, relaunches, and verifies the app stays running. - Per user request: after every edit (code or docs), rebuild and restart using `./Scripts/compile_and_run.sh` so the running app reflects the latest changes. - Release script: keep it in the foreground; do not background it—wait until it finishes. - Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). - Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.*** - Claude CLI status line is custom + user-configurable; never rely on it for usage parsing. - Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed. ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Unreleased ### Highlights - Add Alibaba Coding Plan provider with region-aware quota fetching, widget integration, and browser-cookie import defaults (#574). - Add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain! - Add per-model token counts to cost history (#546). Thanks @iam-brain! - Refactor the Claude provider end to end into clearer, better-tested components while preserving behavior (#494). ### Providers & Usage - Alibaba: add Coding Plan provider support with region-aware web/API quota fetching, widget integration, and browser-cookie import defaults (#574). - Claude: refactor the provider end to end into clearer components, with baseline docs and expanded tests to lock down behavior (#494). - Codex: add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain! - Cost history: add per-model token counts so token usage is broken out by model (#546). Thanks @iam-brain! ### Menu & Settings - Menu: wrap long status blurbs and preserve wrapped titles for multiline entries (#543). Thanks @zkforge! ## 0.18.0 — 2026-03-15 ### Highlights - Add Kilo provider support with API/CLI source modes, widget integration, and pass/credit handling (#454). Built on work by @coreh. - Add Ollama provider, including token-account support in Settings and CLI (#380). Thanks @CryptoSageSnr! - Add OpenRouter provider for credit-based usage tracking (#396). Thanks @chountalas! - Add Codex historical pace with risk forecasting, backfill, and zero-usage-day handling (#482, supersedes #438). Thanks @tristanmanchester! - Add a merged-menu Overview tab with configurable providers and row-to-provider navigation (#416). @ratulsarna - Add an experimental option to suppress Claude Keychain prompts (#388). - Reduce CPU/energy regressions and JSONL scanner overhead in Codex/web usage paths (#402, #392). Thanks @bald-ai and @asonawalla! ### Providers & Usage - Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! - OpenRouter: add provider support with credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! - Gemini: show separate Pro, Flash, and Flash Lite meters by splitting Gemini CLI quota buckets for `gemini-2.5-flash` and `gemini-2.5-flash-lite` (#496). Thanks @aladh - Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131! - Claude: surface rate-limit errors from the CLI `/usage` probe with a user-friendly message, and harden "Failed to load usage data" matching against whitespace-collapsed output. - Claude: restore weekly/Sonnet reset parsing from whitespace-collapsed CLI `/usage` output so reset times and pace details still appear after CLI fallback. - Claude: fix extra-usage double conversion so OAuth/Web values stay on a single normalization path (#472, supersedes #463). Thanks @Priyans-hu! - Claude: remove root-directory mtime short-circuiting in cost scanning so new session logs inside existing `~/.claude/projects/*` folders are discovered reliably (#462, fixes #411). Thanks @Priyans-hu! - Copilot: harden free-plan quota parsing and fallback behavior by treating underdetermined values as unknown, preserving missing metadata as nil (#432, supersedes #393). Thanks @emanuelst! - OpenCode: treat explicit `null` subscription responses as missing usage data, skip POST fallback, and return a clearer workspace-specific error (#412). - OpenCode: surface clearer HTTP errors. Thanks @SalimBinYousuf1! - Codex: preserve exact GPT-5 model IDs in local cost history, add GPT-5.4 pricing, and label zero-cost `gpt-5.3-codex-spark` sessions as "Research Preview" in cost breakdowns (#511). Thanks @iam-brain! - Augment: prevent refresh stalls when `auggie account status` hangs by replacing unbounded CLI waits with timed subprocess execution and fallback handling (#481). Thanks @bryant24hao! - Update Kiro parsing for `kiro-cli` 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun! - Kimi: in automatic metric mode, prioritize the 5-hour rate-limit window for menu bar and merged highest-usage calculations (#390). Thanks @ajaxjiang96! - Browser cookie import: match Gecko `*.default*` profile directories case-insensitively so Firefox/Zen cookie detection works with uppercase `.Default` directories (#422). Thanks @bald-ai! - MiniMax: make both Settings "Open Coding Plan" actions region-aware so China mainland selection opens `platform.minimaxi.com` instead of the global domain (#426, fixes #378). Thanks @bald-ai! - Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh! - Warp: update API key setup guidance. - Claude: update the "not installed" help link to the current Claude Code documentation URL (#431). Thanks @skebby11! - Fix Claude setup message package name (#376). Thanks @daegwang! ### Menu & Settings - Merged menu: keep Merge Icons, the switcher, and Overview tied to user-enabled providers even when some providers are temporarily unavailable, while defaulting menu content and icon state to an available provider when possible (#525). Thanks @Astro-Han! - Merged menu: add an Overview switcher tab that shows up to three provider usage rows in provider order (#416). - Settings: add "Overview tab providers" controls to choose/deselect Overview providers, with persisted selection reconciliation as enabled providers change (#416). - Menu: hide contextual provider actions while Overview is selected and rebuild switcher state when overview availability changes (#416). ### Claude OAuth & Keychain - Add an experimental Claude OAuth Security-CLI reader path and option in settings. - Apply stored prompt mode and fallback policy to silent/noninteractive keychain probes. - Add cooldown for background OAuth keychain retries. - Disable experimental toggle when keychain access is disabled. - Use a `claude-code/` User-Agent for OAuth usage requests instead of a generic identifier. ### Performance & Reliability - Codex/OpenAI web: reduce CPU and energy overhead by shortening failed CLI probe windows, capping web retry timeouts, and using adaptive idle blink scheduling (#402). Thanks @bald-ai! - Cost usage scanner: optimize JSONL chunk parsing to avoid buffer-front removal overhead on large logs (#392). Thanks @asonawalla! - TTY runner: fence shutdown registration to avoid launch/shutdown races, isolate process groups before shutdown rejection, and ensure lingering CLI descendants are cleaned up on app termination (#429). Thanks @uraimo! ## 0.18.0-beta.3 — 2026-02-13 ### Highlights - Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12! - Claude: harden Claude Code PTY capture for `/usage` and `/status` (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320). - New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu! - Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44! - Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs! - CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290). ### Claude OAuth & Keychain - Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts. - Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required. - In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh). - OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back. - Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive. ### Provider & Usage Fixes - Warp: add Warp provider support (credits + add-on credits), configurable via Settings or `WARP_API_KEY`/`WARP_TOKEN` (#352). Thanks @Kathie-yu! - Cursor: compute usage against `plan.limit` rather than `breakdown.total` to avoid incorrect limit interpretation (#240). Thanks @robinebers! - MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44! - MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan! - Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf! - z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin! - z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment). - Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden! - Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev! - OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07! - Token-account precedence: selected token account env injection now correctly overrides provider config `apiKey` values in app and CLI environments. Thanks @arvindcr4! - Claude: make Claude CLI probing more resilient by scoping auto-input to the active subcommand and trimming to the latest Usage panel before parsing to avoid false matches from earlier screen fragments (#320). ### Menu Bar & UI Behavior - Prevent fallback-provider loading animation loops (battery/CPU drain when no providers are enabled) (#283). Thanks @vignesh07! - Prevent status overlay rendering for disabled providers while in merged mode (#291). Thanks @Ilakiancs! ### CI, Tooling & Test Stability - Pin SwiftFormat/SwiftLint versions and harden lint installer behavior (version drift + temp-file leak fixes) (#292). - Use more deterministic macOS CI test settings (including non-parallel paths where needed) and align runner/toolchain behavior for stability (#292). - Stabilize PTY command timing tests to reduce CI flakiness (#312). - Upgrade `actions/checkout` to v6 and `actions/github-script` to v8 for Node 24 compatibility in `upstream-monitor.yml` (#290). Thanks @salmanmkc! - Tests: add TaskLocal-based keychain/cache overrides so keychain gating and KeychainCacheStore test stores do not leak across concurrent test execution (#320). ### Docs & Maintenance - Update docs for Claude data fetch behavior and keychain troubleshooting notes. - Update MIT license year. ## 0.18.0-beta.2 — 2026-01-21 ### Highlights - OpenAI web dashboard refresh cadence now follows 5× the base refresh interval. - OpenAI web dashboard WebView is kept warm between scrapes to avoid repeated SPA downloads while idle CPU stays low (#284). Thanks @vignesh07! - Menu bar: avoid fallback animation loop when all providers are disabled (#283). Thanks @vignesh07! - Codex settings now include a toggle to disable OpenAI web extras. ### Providers - Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev! - Codex: include archived session logs in local token cost scanning and dedupe by session id. - Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes. ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. - Menu: fix usage line labels to honor “Show usage as used”. - Debug: add a toggle to keep Codex/Claude CLI sessions alive between probes. - Debug: add a button to reset CLI probe sessions. - App icon: use the classic icon on macOS 15 and earlier while keeping Liquid Glass for macOS 26+ (#178). Thanks @zerone0x! ## 0.18.0-beta.1 — 2026-01-18 ### Highlights - New providers: OpenCode (web usage), Vertex AI, Kiro, Kimi, Kimi K2, Augment, Amp, Synthetic. - Provider source controls: usage source pickers for Codex/Claude, manual cookie headers, cookie caching with source/timestamp. - Menu bar upgrades: display mode picker (percent/pace/both), auto-select near limit, absolute reset times, pace summary line. - CLI/config revamp: config-backed provider settings, JSON-only errors, config validate/dump. ### Providers - OpenCode: add web usage provider with workspace override + Chrome-first cookie import (#188). Thanks @anthnykr! - OpenCode: refresh provider logo (#190). Thanks @anthnykr! - Vertex AI: add provider with quota-based usage from gcloud ADC. Thanks @bahag-chaurasiak! - Vertex AI: token costs are shown via the Claude provider (same local logs). - Vertex AI: harden quota usage parsing for edge-case responses. - Kiro: add CLI-based usage provider via kiro-cli. Thanks @neror! - Kiro: clean up provider wiring and show plan name in the menu. - Kiro: harden CLI idle handling to avoid partial usage snapshots (#145). Thanks @chadneal! - Kimi: add usage provider with cookie-based API token stored in Keychain (#146). Thanks @rehanchrl! - Kimi K2: add API-key usage provider for credit totals (#147). Thanks @0-CYBERDYNE-SYSTEMS-0! - Augment: add provider with browser-cookie usage tracking. - Augment: prefer Auggie CLI usage with web fallback, plus session refresh + recovery tools (#142). Thanks @bcharleson! - Amp: add provider with Amp Free usage tracking (#167). Thanks @duailibe! - Synthetic: add API-key usage provider with quota snapshots (#171). Thanks @monotykamary! - JetBrains AI: include IDEs missing quota files, expand custom paths, and add Android Studio base paths (#194). Thanks @steipete! - JetBrains AI: detect IDE directories case-insensitively (#200). Thanks @zerone0x! - Cursor: support legacy request-based plans and show individual on-demand usage (#125) — thanks @vltansky - Cursor: avoid Intel crash when opening login and harden WebKit teardown. Thanks @meghanto! - Cursor: load stored session cookies before reads to make relaunches deterministic. - z.ai: add BigModel CN region option for API endpoint selection (#140). Thanks @nailuoGG! - MiniMax: add China mainland region option + host overrides (#143). Thanks @nailuoGG! - MiniMax: support API token or cookie auth; API token takes precedence and hides cookie UI (#149). Thanks @aonsyed! - Gemini: prefer loadCodeAssist project IDs for quota fetches (#172). Thanks @lolwierd! - Gemini: honor loadCodeAssist project IDs for quota + support Nix CLI layout (#184). Thanks @HaukeSchnau! - Claude: fix OAuth “Extra usage” spend/limit units when the API returns minor currency units (#97). - Claude: rescale extra usage costs when plan hints are missing and prefer web plan hints for extras (#181). Thanks @jorda0mega! - Usage formatting: fix currency parsing/formatting on non-US locales (e.g., pt-BR). Thanks @mneves75! ### Provider Sources & Security - Providers: cache browser cookies in Keychain (per provider) and show cached source/time in settings. - Codex/Claude/Cursor/Factory/MiniMax: cookie sources now include Manual (paste a Cookie header) in addition to Automatic. - Codex/Claude/Cursor/Factory/MiniMax: skip cookie imports from browsers without usable cookie stores (profile/cookie DB) to avoid unnecessary Keychain prompts. - Providers: suppress repeated Chromium Keychain prompts after access denied and honor disabled Keychain access. ### Preferences & Settings - Preferences: swap provider refresh button and enable toggle order. - Preferences: animate settings width and widen Providers on selection. - Preferences: shrink default settings size and reduce overall height. - Preferences: move “Hide personal information” to Advanced. - Providers: shorten fetch subtitle to relative time only. - Preferences: soften provider sidebar background and stabilize drag reordering. - Preferences: restrict provider drag handle to handle-only. - Preferences: move provider refresh timing to a dedicated second line. - Preferences: tighten provider usage metrics spacing. - Preferences: show refresh timing inline in provider detail subtitle. - Preferences: move “Access OpenAI via web” into Providers → Codex. - Preferences: add usage source pickers for Codex + Claude with auto fallback. - Preferences: add cookie source pickers with contextual helper text for the selected mode. - Preferences: move “Disable Keychain access” to Advanced and require manual cookies when enabled. - Preferences: add per-provider menu bar metric picker (#185) — thanks @HaukeSchnau - Preferences: tighten provider rows (inline pickers, compact layout, inline refresh + auto-source status). - Preferences: remove the “experimental” label from Antigravity. ### Menu & Menu Bar - Menu: add a toggle to show reset times as absolute clock values (instead of countdowns). - Menu: show an “Open Terminal” action when Claude OAuth fails. - Menu: add “Hide personal information” toggle and redact emails in menu UI (#137). Thanks @t3dotgg! - Menu: keep a pace summary line alongside the visual marker (#155). Thanks @antons! - Menu: reduce provider-switch flicker and avoid redundant menu card sizing for faster opens (#132). Thanks @ibehnam! - Menu: keep background refresh on open without forcing token usage (#158). Thanks @weequan93! - Menu: Cursor switcher shows On-Demand remaining when Plan is exhausted in show-remaining mode (#193). Thanks @vltansky! - Menu: avoid single-letter wraps in provider switcher titles. - Menu: widen provider switcher buttons to avoid clipped titles. - Menu bar: rebuild provider status items on reorder so icons update correctly. - Menu bar: optional auto-select provider closest to its rate limit and keep switcher progress visible (#159). Thanks @phillco! - Menu bar: add display mode picker for percent/pace/both in the menu bar icon (#169). Thanks @PhilETaylor! - Menu bar: fix combined loading indicator flicker during loading animation (incl. debug replay). - Menu bar: prevent blink updates from clobbering the loading animation. ### CLI & Config - CLI: respect the reset time display setting. - CLI: add pink accents, usage bars, and weekly pace lines to text output. - CLI: add config-backed provider settings, `--json-only`, and `--source api` for key-based providers. - CLI: add `config validate`/`config dump` commands and per-provider JSON error payloads. - CLI/App: move provider secrets + ordering to `~/.codexbar/config.json` (no Keychain persistence). - Providers: resolve API tokens from config/env only (no Keychain fallback). ### Dev & Tests - Dev: move Chromium profile discovery into SweetCookieKit (adds Helium net.imput.helium). Thanks @hhushhas! - Dev: bump SweetCookieKit to 0.2.0. - Dev: migrate stored Keychain items to reduce rebuild prompts. - Dev: move path debug snapshot off the main thread and debounce refreshes to avoid startup hitches (#131). Thanks @ibehnam! - Tests: expand Kiro CLI coverage. - Tests: stabilize Claude PTY integration cleanup and reset CLI sessions after probes. - Tests: kill leaked codex app-server after tests. - Tests: add regression coverage for merged loading icon layout stability. - Tests: cover config validation and JSON-only CLI errors. - Build: stabilize Swift test runtime. ## 0.17.0 — 2025-12-31 - New providers: MiniMax. - Keychain: show a preflight explanation before macOS prompts for OAuth tokens or cookie decryption. - Providers: defer z.ai + Copilot Keychain reads until the user interacts with the token field. - Menu bar: avoid status item menu reattachment and layout flips during refresh to reduce icon flicker. - Dev: align SweetCookieKit local-storage tests with Swift Testing. - Charts: align hover selection bands with visible bars in credits + usage breakdown history. - About: fix website link in the About panel. Thanks @felipeorlando! ## 0.16.1 — 2025-12-29 - Menu: reduce layout thrash when opening menus and sizing charts. Thanks @ibehnam! - Packaging: default release notarization builds universal (arm64 + x86_64) zip. - OpenAI web: reduce idle CPU by suspending cached WebViews when not scraping. Thanks @douglascamata! - Icons: switch provider brand icons to SVGs for sharper rendering. Thanks @vandamd! ## 0.16.0 — 2025-12-29 - Menu bar: optional “percent mode” (provider brand icons + percentage labels) via Advanced toggle. - CLI: add `codexbar cost` to print local cost usage (text/JSON) for Codex + Claude. - Cost: align local cost scanner with ccusage; stabilize parsing/decoding and handle large JSONL lines. - Claude: skip pricing for unknown models (tokens still tracked) to avoid hard-coded legacy prices. - Performance: reduce menu bar CPU usage by caching morph icons, skipping redundant status-item updates, and caching provider enablement/order during animations. - Menu: improve provider switcher hover contrast in light mode. - Icons: refresh Droid + Claude brand assets to better match menu sizing. - CI: avoid interactive login-shell probes to reduce noisy “CLI missing” errors. ## 0.15.3 — 2025-12-28 - Codex: default to OAuth usage API (ChatGPT backend) with CLI-only override in Debug. - Codex: map OAuth credits balance directly, avoiding web fallback for credits. - Preferences: add optional “Access OpenAI via web” toggle and show blended source labels when web extras are active. - Copilot: replace blocking auth wait dialog with a non-modal sheet to avoid stuck login. ## 0.15.2 — 2025-12-28 - Copilot: fix device-flow waiting modal to close reliably after auth (and avoid stuck waits). - Packaging: include the KeyboardShortcuts resource bundle to prevent Settings → Keyboard shortcut crashes in packaged builds. ## 0.15.1 — 2025-12-28 - Preferences: fix provider API key fields reusing the wrong input when switching rows. - Preferences: avoid Advanced tab crash when opening settings. ## 0.15.0 — 2025-12-28 - New providers: Droid (Factory), Cursor, z.ai, Copilot. - macOS: CodexBar now supports Intel Macs (x86_64 builds + Sonoma fallbacks). Thanks @epoyraz! - Droid (Factory): new provider with Standard + Premium usage via browser cookies, plus dashboard + status links. Thanks @shashank-factory! - Menu: allow multi-line error messages in the provider subtitle (up to 4 lines). - Menu: fix subtitle sizing for multi-line error states. - Menu: avoid clipping on multi-line error subtitles. - Menu: widen the menu card when 7+ providers are enabled. - Providers: Codex, Claude Code, Cursor, Gemini, Antigravity, z.ai. - Gemini: switch plan detection to loadCodeAssist tier lookup (Paid/Workspace/Free/Legacy). Thanks @381181295! - Codex: OpenAI web dashboard is now the primary source for usage + credits; CLI fallback only when no matching cookies exist. - Claude: prefer OAuth when credentials exist; fall back to web cookies or CLI (thanks @ibehnam). - CLI: replace `--web`/`--claude-source` with `--source` (auto/web/cli/oauth); auto falls back only when cookies are missing. - Homebrew: cask now installs the `codexbar` CLI symlink. Thanks @dalisoft! - Cursor: add new usage provider with browser cookie auth (cursor.com + cursor.sh), on-demand bar support, and dashboard access. - Cursor: keep stored sessions on transient failures; clear only on invalid auth. - z.ai: new provider support with Tokens + MCP usage bars and MCP details submenu; API token now lives in Preferences (stored in Keychain); usage bars respect the show-used toggle. Thanks @uwe-schwarz for the initial work! - Copilot: new GitHub Copilot provider with device flow login plus Premium + Chat usage bars (including CLI support). Thanks @roshan-c! - Preferences: fix Advanced Display checkboxes and move the Quit button to the bottom of General. - Preferences: hide “Augment Claude via web” unless Claude usage source is CLI; rename the cost toggle to “Show cost summary”. - Preferences: add an Advanced toggle to show/hide optional Codex Credits + Claude Extra usage sections (on by default). - Widgets: add a new “CodexBar Switcher” widget that lets you switch providers and remember the selection. - Menu: provider switcher now uses crisp brand icons with equal-width segments and a per-provider usage indicator. - Menu: tighten provider switcher sizing and increase spacing between label and weekly indicator bar. - Menu: provider switcher no longer forces a wider menu when many providers are enabled; segments clamp to the menu width. - Menu: provider switcher now aligns to the same horizontal padding grid as the menu cards when space allows. - Dev: `compile_and_run.sh` now force-kills old instances to avoid launching duplicates. - Dev: `compile_and_run.sh` now waits for slow launches (polling for the process). - Dev: `compile_and_run.sh` now launches a single app instance (no more extra windows). - CI: build/test Linux `CodexBarCLI` (x86_64 + aarch64) and publish release assets as `CodexBarCLI--linux-.tar.gz` (+ `.sha256`). - CLI: add alias fallback for Codex/Claude detection when PATH lookups fail. - Providers: support Arc browser cookies for Factory/Droid (and other Chromium-based cookie imports). - Providers: support ChatGPT Atlas browser data for Chromium cookie imports. - Providers: accept Auth.js secure session cookies for Factory/Droid login detection. - Providers: accept Factory auth session cookies (session/access-token) for Droid. - Droid: surface Factory API errors instead of masking them as missing sessions. - Droid: retry auth without access-token cookies when Factory flags a stale token. - Droid: try all detected browser profiles before giving up. - Droid: fall back to auth.factory.ai endpoints when cookies live on the auth host. - Droid: use WorkOS refresh tokens from browser local storage when cookies fail. - Droid: read WorkOS refresh tokens from Safari local storage. - Droid: try stored/WorkOS tokens before Chrome cookies to reduce Chrome Safe Storage prompts. - Menu: provider switcher bars now track primary quotas (Plan/Tokens/Pro), with Premium shown for Droid. - Menu: avoid duplicate summary blocks when a provider has no action rows. - OpenAI web: ignore cookie sets without session tokens to avoid false-positive dashboard fetches. - Providers: hide z.ai in the menu until an API key is set. - Menu: refresh runs automatically when opening the menu with a short retry (refresh row removed). - Menu: hide the Status Page row when a provider has no status URL. - Menu: align switcher bar with the “show usage as used” toggle. - Antigravity: fix lsof port filtering by ANDing listen + pid conditions. Thanks @shaw-baobao! - Claude: default to Claude Code OAuth usage API (credentials from Keychain or `~/.claude/.credentials.json`), with Debug selector + `--claude-source` CLI override (OAuth/Web/CLI). - OpenAI web: allow importing any signed-in browser session when Codex email is unknown (first-run friendly). - Core: Linux CLI builds now compile (mac-only WebKit/logging gated; FoundationNetworking imports where needed). - Core: fix CI flake for Claude trust prompts by making PTY writes fully reliable. - Core: Cursor provider is macOS-only (Linux CLI builds stub it). - Core: make `RateWindow` equatable (used by OpenAI dashboard snapshots and tests). - Tests: cover alias fallback resolution for Codex/Claude and add Linux platform gating coverage (run in CI). - Tests: cover hiding Codex Credits + Claude Extra usage via the Advanced toggle. - Docs: expand CLI docs for Linux install + flags. ## 0.14.0 — 2025-12-25 - New providers: Antigravity. - Antigravity: new local provider for the Antigravity language server (Claude + Gemini quotas) with an experimental toggle; improved plan display + debug output; clearer not-running/port errors; hide account switch. - Status: poll Google Workspace incidents for Gemini + Antigravity; Status Page opens the Workspace status page. - Settings: add Providers tab; move ccusage + status toggles to General; keep display controls in Advanced. - Menu/UI: widen the menu for four providers; cards/charts adapt to menu width; tighten provider switcher/toggle spacing; keep menus refreshed while open. - Gemini: hide the dashboard action when unsupported. - Claude: fix Extra usage spend/limit units (cents); improve CLI probe stability; surface web session info in Debug. - OpenAI web: fix dashboard ghost overlay on desktop (WebKit keepalive window). - Debug: add a debug-lldb build mode for troubleshooting. ## 0.13.0 — 2025-12-24 - Claude: add optional web-first usage via Safari/Chrome cookies (no CLI fallback) including “Extra usage” budget bar. - Claude: web identity now uses `/api/account` for email + plan (via rate_limit_tier). - Settings: standardize “Augment … via web” copy for Codex + Claude web cookie features. - Debug: Claude dump now shows web strategy, cookie discovery, HTTP status codes, and parsed summary. - Dev: add Claude web probe CLI to enumerate endpoints/fields using browser cookies. - Tests: add unit coverage for Claude web API usage, overage, and account parsing. - Menu: custom menu items now use the native selection highlight color (plus matching selection text/track colors). - Charts: boost hover highlight contrast for credits/usage history bands. - Menu: reorder Codex blocks to show credits before cost. - Menu: split Claude “Extra usage” (no submenu) from “Cost” (history submenu) and trim redundant extra-usage subtext. ## 0.12.0 — 2025-12-23 - Widgets: add WidgetKit extension backed by a shared app‑group usage snapshot. - New local cost usage tracking (Codex + Claude) via a lightweight scanner — inspired by ccusage (MIT). Computes cost from local JSONL logs without Node CLIs. Thanks @ryoppippi! - Cost summary now includes last‑30‑days tokens; weekly pace indicators (with runout copy) hide when usage is fully depleted. Thanks @Remedy92! - Claude: PTY probes now stop after idle, auto‑clean on restart, and run under a watchdog to avoid runaway CLI processes. - Menu polish: group history under card sections, simplify history labels, and refresh menus live while open. - Performance: faster usage log scanning + cost parsing; cache menu icons and speed up OpenAI dashboard parsing. - Sparkle: auto-download updates when auto-check is enabled, and only show the restart menu entry once an update is ready. - Widgets: experimental WidgetKit extension (may require restarting the widget gallery/Dock to appear). - Credits: show credits as a progress bar and add a credits history chart when OpenAI web data is available. - Credits: move “Buy Credits…” into its own menu item and improve auto-start checkout flow. ## 0.11.2 — 2025-12-21 - ccusage-codex cost fetch is faster and more reliable by limiting the session scan window. - Fix ccusage cost fetch hanging for large Codex histories by draining subprocess output while commands run. - Fix merged-icon loading animation when another provider is fetching (only the selected provider animates). - CLI PATH capture now uses an interactive login shell and merges with the app PATH, fixing missing Node/Codex/Claude/Gemini resolution for NVM-style installs. ## 0.11.1 — 2025-12-21 - Gemini OAuth token refresh now supports Bun/npm installations. Thanks @ben-vargas! ## 0.11.0 — 2025-12-21 - New optional cost display in the menu (session + last 30 days), powered by ccusage. Thanks @Xuanwo! - Fix loading-state card spacing to avoid double separators. ## 0.10.0 — 2025-12-20 - Gemini provider support (usage, plan detection, login flow). Thanks @381181295! - Unified menu bar icon mode with a provider switcher and Merge Icons toggle (default on when multiple providers are enabled). Thanks @ibehnam! - Fix regression from 0.9.1 where CLI detection failed for some installs by restoring interactive login-shell PATH loading. ## 0.9.1 — 2025-12-19 - CLI resolution now uses the login shell PATH directly (no more heuristic path scanning), so Codex/Claude match your shell config reliably. ## 0.9.0 — 2025-12-19 - New optional OpenAI web access: reuses your signed-in Safari/Chrome session to show **Code review remaining**, **Usage breakdown**, and **Credits usage history** in the menu (no credentials stored). - Credits still come from the Codex CLI; OpenAI web access is only used for the dashboard extras above. - OpenAI web sessions auto-sync to the Codex CLI email, support multiple accounts, and reset/re-import cookies on account switches to avoid stale cross-account data. - Fix Chrome cookie import (macOS 10): signed-in Chrome sessions are detected reliably (thanks @tobihagemann!). - Usage breakdown submenu: compact chart with hover details for day/service totals. - New “Show usage as used” toggle to invert progress bars (default remains “% left”, now in Advanced). - Session (5-hour) reset now shows a relative countdown (“Resets in 3h 31m”) in the menu card for Codex and Claude. - Claude: fix reset parsing so “Resets …” can’t be mis-attributed to the wrong window (session vs weekly). ## 0.8.1 — 2025-12-17 - Claude trust prompts (“Do you trust the files in this folder?”) are now auto-accepted during probes to prevent stuck refreshes. Thanks @tobihagemann! ## 0.8.0 — 2025-12-17 - CodexBar is now available via Homebrew: `brew install --cask steipete/tap/codexbar` (updates via `brew upgrade --cask steipete/tap/codexbar`). - Added session quota notifications for the sliding 5-hour window (Codex + Claude): notifies when it hits 0% and when it’s available again, based only on observed refresh data (including startup when already depleted). Thanks @GKannanDev! ## 0.7.3 — 2025-12-17 - Claude Enterprise accounts whose Claude Code `/usage` panel only shows “Current session” no longer fail parsing; weekly usage is treated as unavailable (fixes #19). ## 0.7.2 — 2025-12-13 - Claude “Open Dashboard” now routes subscription accounts (Max/Pro/Ultra/Team) to the usage page instead of the API console billing page. Thanks @auroraflux! - Codex/Claude binary resolution now detects mise/rtx installs (shims and newest installed tool version), fixing missing CLI detection for mise users. Thanks @philipp-spiess! - Claude usage/status probes now auto-accept the first-run “Ready to code here?” permission prompt (when launched from Finder), preventing timeouts and parse errors. Thanks @alexissan! - General preferences now surface full Codex/Claude fetch errors with one-click copy and expandable details, reducing first-run confusion when a CLI is missing. - Polished the menu bar “critter” icons: Claude is now a crisper, blockier pixel crab, and Codex has punchier eyes with reduced blurring in SwiftUI/menu rendering. ## 0.7.1 — 2025-12-09 - Menu bar icons now render on a true 18 pt/2× backing with pixel-aligned bars and overlays for noticeably crisper edges. - PTY runner now preserves the caller’s environment (HOME/TERM/bun installs) while enriching PATH, preventing Codex/Claude probes from failing when CLIs are installed via bun/nvm or need their auth/config paths. - Added regression tests to lock in the enriched environment behavior. - Fixed a first-launch crash on macOS 26 caused by the 1×1 keepalive window triggering endless constraint updates; the hidden window now uses a safe size and no longer spams SwiftUI state warnings. - Menu action rows now ship with SF Symbol icons (refresh, dashboard, status, settings, about, quit, copy error) for clearer at-a-glance affordances. - When the Codex CLI is missing, menu and CLI now surface an actionable install hint (`npm i -g @openai/codex` / bun) instead of a generic PATH error. - Node manager (nvm/fnm) resolution corrected so codex/claude binaries — and their `node` — are found reliably even when installed via fnm aliases or nvm defaults. Thanks @aliceisjustplaying for surfacing the gaps. - Login menu now shows phase-specific subtitles and disables interaction while running: “Requesting login…” while starting the CLI, then “Waiting in browser…” once the auth URL is printed; success still triggers the macOS notification. - Login state is tracked per provider so Codex and Claude icons/menus no longer share the same in-flight status when switching accounts. - Claude login PTY runner detects the auth URL without clearing buffers, keeps the session alive until confirmation, and exposes a Sendable phase callback used by the menu. - Claude CLI detection now includes Claude Code’s self-updating paths (`~/.claude/local/claude`, `~/.claude/bin/claude`) so PTY probes work even when only the bundled installer is used. ## 0.7.0 — 2025-12-07 - ✨ New rich menu card with inline progress bars and reset times for each provider, giving the menu a beautiful, at-a-glance dashboard feel (credit: Anton Sotkov @antons). ## 0.6.1 — 2025-12-07 - Claude CLI probes stop passing `--dangerously-skip-permissions`, aligning with the default permission prompt and avoiding hidden first-run failures. ## 0.6.0 — 2025-12-04 - New bundled CLI (`codexbar`) with single `usage` command, `--format text|json`, `--status`, and fast `-h/-V`. - CLI output now shows consistent headers (`Codex 0.x.y (codex-cli)`, `Claude Code (claude)`) and JSON includes `source` + `status`. - Advanced prefs install button symlinks `codexbar` into /usr/local/bin and /opt/homebrew/bin; docs refreshed. ## 0.5.7 — 2025-11-26 - Status Page and Usage Dashboard menu actions now honor the icon you click; Codex menus no longer open the Claude status site. ## 0.5.6 — 2025-11-25 - New playful “Surprise me” option adds occasional blinks/tilts/wiggles to the menu bar icons (one random effect at a time) plus a Debug “Blink now” trigger. - Preferences now include an Advanced tab (refresh cadence, Surprise me toggle, Debug visibility); window height trimmed ~20% for a tighter fit. - Motion timing eased and lengthened so blinks/wiggles feel smoother and less twitchy. ## 0.5.5 — 2025-11-25 - Claude usage scrape now recognizes the new “Current week (Sonnet only)” bar while keeping the legacy Opus label as a fallback. - Menu and docs now label the Claude tertiary limit as Sonnet to match the latest CLI wording. - PATH seeding now uses a deterministic binary locator plus a one-shot login-shell capture at startup (no globbed nvm paths); the Debug tab shows the resolved Codex binary and effective PATH layers. ## 0.5.4 — 2025-11-24 - Status blurb under “Status Page” no longer prefixes the text with “Status:”, keeping the incident description concise. - PTY runner now registers cleanup before launch so both ends of the TTY and the process group are torn down even when `Process.run()` throws (no leaked fds when spawn fails). ## 0.5.3 — 2025-11-22 - Added a per-provider “Status Page” menu item beneath Usage that opens the provider’s live status page (OpenAI or Claude). - Status API now refreshes alongside usage; incident states show a dot/! overlay on the status icon plus a status blurb under the menu item. - General preferences now include a default-on “Check provider status” toggle above refresh cadence. ## 0.5.2 — 2025-11-22 - Release packaging now includes uploading the dSYM archive alongside the app zip to aid crash symbolication (policy documented in the shared mac release guide). - Claude PTY fallback removed: Claude probes now rely solely on `script` stdout parsing, and the generic TTY runner is trimmed to Codex `/status` handling. - Fixed a busy-loop on the codex RPC stderr pipe (handler now detaches on EOF), eliminating the long-running high-CPU spin reported in issue #9. ## 0.5.1 — 2025-11-22 - Debug pane now exposes the Claude parse dump toggle, keeping the captured raw scrape in memory for inspection. - Claude About/debug views embed the current git hash so builds can be identified precisely. - Minor runtime robustness tweaks in the PTY runner and usage fetcher. ## 0.5.0 — 2025-11-22 - Codex usage/credits now use the codex app-server RPC by default (with PTY `/status` fallback when RPC is unavailable), reducing flakiness and speeding refreshes. - Codex CLI launches seed PATH with Homebrew/bun/npm/nvm/fnm defaults to avoid ENOENT in hardened/release builds; TTY probes reuse the same PATH. - Claude CLI probe now runs `/usage` and `/status` in parallel (no simulated typing), captures reset strings, and uses a resilient parser (label-first with ordered fallback) while keeping org/email separate by provider. - TTY runner now always tears down the spawned process group (even on early Claude login prompts) to avoid leaking CLI processes. - Default refresh cadence is now 5 minutes, and a 15-minute option was added to the settings picker. - Claude probes/version detection now start with `--allowed-tools ""` (tool access disabled) while keeping interactive PTY mode working. - Codex probes and version detection now launch the CLI with `-s read-only -a untrusted` to keep PTY runs sandboxed. - Codex warm-up screens (“data not available yet”) are handled gracefully: cached credits stay visible and the menu skips the scary parse error. - Codex reset times are shown for both RPC and TTY fallback, and plan labels are capitalized while emails stay verbatim. ## 0.4.3 — 2025-11-21 - Fix status item creation timing on macOS 15 by deferring NSStatusItem setup to after launch; adds a regression test for the path. - Menu bar icon with unknown usage now draws empty tracks (instead of a full bar when decorations are shown) by treating nil values as 0%. ## 0.4.2 — 2025-11-21 - Sparkle updates re-enabled in release builds (disabled only for the debug bundle ID). ## 0.4.1 — 2025-11-21 - Both Codex and Claude probes now run off the main thread (background PTY), avoiding menu/UI stalls during `/status` or `/usage` fetches. - Codex credits stay available even when `/status` times out: cached values are kept and errors are surfaced separately. - Claude/Codex provider autodetect runs on first launch (defaults to Codex if neither is installed) with a debug reset button. - Sparkle updates re-enabled in release builds (disabled only for debug bundle ID). - Claude probe now issues the `/usage` slash command directly to land on the Usage tab reliably and avoid palette misfires. ## 0.4.0 — 2025-11-21 - Claude Code support: dedicated Claude menu/icon plus dual-wired menus when both providers are enabled; shows email/org/plan and Sonnet usage with clickable errors. - New Preferences window: General/About tabs with provider toggles, refresh cadence, start-at-login, and always-on Quit. - Codex credits without web login: we now read `codex /status` in a PTY, auto-skip the update prompt, and parse session/weekly/credits; cached credits stay visible on transient timeouts. - Resilience: longer PTY timeouts, cached-credit fallback, one-line menu errors, and clearer parse/update messages. ## 0.3.0 — 2025-11-18 - Credits support: reads Codex CLI `/status` via PTY (no browser login), shows remaining credits inline, and moves history to a submenu. - Sign-in window with cookie reuse and a logout/clear-cookies action; waits out workspace picker and auto-navigates to usage page. - Menu: credits line bolded; login prompt hides once credits load; debug toggle always visible (HTML dump). - Icon: when weekly is empty, top bar becomes a thick credits bar (capped at 1k); otherwise bars stay 5h/weekly. ## 0.2.2 — 2025-11-17 - Menu bar icon stays static when no account/usage is present; loading animation only runs while fetching (12 fps) to keep idle CPU low. - Usage refresh first tails the newest session log (512 KB window) before scanning everything, reducing IO on large Codex logs. - Packaging/signing hardened: strip extended attributes, delete AppleDouble (`._*`) files, and re-sign Sparkle + app bundle to satisfy Gatekeeper. ## 0.2.1 — 2025-11-17 - Patch bump for refactor/relative-time changes; packaging scripts set to 0.2.1 (5). - Streamlined Codex usage parsing: modern rate-limit handling, flexible reset time parsing, and account rate-limit updates (thanks @jazzyalex and https://jazzyalex.github.io/agent-sessions/). ## 0.2.0 — 2025-11-16 - CADisplayLink-based loading animations (macOS 15 displayLink API) with randomized patterns (Knight Rider, Cylon, outside-in, race, pulse) and debug replay cycling through all. - Debug replay toggle (`defaults write com.steipete.codexbar debugMenuEnabled -bool YES`) to view every pattern. - Usage Dashboard link in menu; menu layout tweaked. - Updated time now shows relative formatting when fresher than 24h; refactored sources into smaller files for maintainability. - Version bumped to 0.2.0 (4). ## 0.1.2 — 2025-11-16 - Animated loading icon (dual bars sweep until usage arrives); always uses rendered template icon. - Sparkle embedding/signing fixed with deep+timestamp; notarization pipeline solid. - Icon conversion scripted via ictool with docs. - Menu: settings submenu, no GitHub item; About link clickable. ## 0.1.1 — 2025-11-16 - Launch-at-login toggle (SMAppService) and saved preference applied at startup. - Sparkle auto-update wiring (SUFeedURL to GitHub, SUPublicEDKey set); Settings submenu with auto-update toggle + Check for Updates. - Menu cleanup: settings grouped, GitHub menu removed, About link clickable. - Usage parser scans newest session logs until it finds `token_count` events. - Icon pipeline fixed: regenerated `.icns` via ictool with proper transparency (docs in docs/icon.md). - Added lint/format configs, Swift Testing, strict concurrency, and usage parser tests. - Notarized release build "CodexBar-0.1.0.zip" remains current artifact; app version 0.1.1. ## 0.1.0 — 2025-11-16 - Initial CodexBar release: macOS 15+ menu bar app, no Dock icon. - Reads latest Codex CLI `token_count` events from session logs (5h + weekly usage, reset times); no extra login or browser scraping. - Shows account email/plan decoded locally from `auth.json`. - Horizontal dual-bar icon (top = 5h, bottom = weekly); dims on errors. - Configurable refresh cadence, manual refresh, and About links. - Async off-main log parsing for responsiveness; strict-concurrency build flags enabled. - Packaging + signing/notarization scripts (arm64); build scripts convert `.icon` bundle to `.icns`. ================================================ FILE: FORK_STATUS.md ================================================ # CodexBar Fork - Current Status **Last Updated:** January 4, 2026 **Fork Maintainer:** Brandon Charleson **Branch:** `feature/augment-integration` --- ## ✅ Completed Work ### Phase 1: Fork Identity & Credits ✓ **Commits:** 1. `da3d13e` - "feat: establish fork identity with dual attribution" 2. `745293e` - "docs: add fork roadmap and quick start guide" 3. `8a87473` - "docs: add fork status tracking document" 4. `df75ae2` - "feat: comprehensive multi-upstream fork management system" **Changes:** - ✅ Updated About section with dual attribution (original + fork) - ✅ Updated PreferencesAboutPane with organized sections - ✅ Changed app icon click to open fork repository - ✅ Updated README with fork notice and enhancements section - ✅ Created comprehensive `docs/augment.md` documentation - ✅ Created `docs/FORK_ROADMAP.md` with 5-phase plan - ✅ Created `docs/FORK_QUICK_START.md` developer guide - ✅ Created `FORK_STATUS.md` tracking document - ✅ **Implemented complete multi-upstream management system** **Build Status:** ✅ App builds and runs successfully ### Multi-Upstream Management System ✓ **Automation Scripts:** - ✅ `Scripts/check_upstreams.sh` - Monitor both upstreams - ✅ `Scripts/review_upstream.sh` - Create review branches - ✅ `Scripts/prepare_upstream_pr.sh` - Prepare upstream PRs - ✅ `Scripts/analyze_quotio.sh` - Analyze quotio patterns **GitHub Actions:** - ✅ `.github/workflows/upstream-monitor.yml` - Automated monitoring **Documentation:** - ✅ `docs/UPSTREAM_STRATEGY.md` - Complete management guide - ✅ `docs/QUOTIO_ANALYSIS.md` - Pattern analysis framework - ✅ `docs/FORK_SETUP.md` - One-time setup guide --- ## 🎯 Current State ### What Works - ✅ Fork identity clearly established - ✅ Dual attribution in place (original + fork) - ✅ Comprehensive documentation - ✅ Clear development roadmap - ✅ App builds without errors - ✅ All existing functionality preserved - ✅ **Multi-upstream management system operational** - ✅ **Automated upstream monitoring configured** - ✅ **Quotio analysis framework ready** ### Critical Discovery - ⚠️ **Upstream (steipete) has REMOVED Augment provider** - 627 lines deleted from `AugmentStatusProbe.swift` - 88 lines deleted from `AugmentStatusProbeTests.swift` - **This validates our fork strategy!** - We preserve Augment support for our users - We can selectively sync other improvements ### Known Issues - ⚠️ Augment cookie disconnection (Phase 2 will address) - ⚠️ Debug print statements in AugmentStatusProbe.swift (needs proper logging) ### Uncommitted Changes - `Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift` has debug print statements - These should be replaced with proper `CodexBarLog` logging in Phase 2 - Currently unstaged to keep commits clean --- ## 📋 Next Steps ### URGENT: Upstream Sync Decision **Before proceeding with Phase 2, decide on upstream sync strategy:** 1. **Review upstream changes:** ```bash ./Scripts/check_upstreams.sh upstream ./Scripts/review_upstream.sh upstream ``` 2. **Decide what to sync:** - ✅ Vertex AI improvements (5 commits) - ✅ SwiftFormat/SwiftLint fixes - ❌ Augment provider removal (SKIP!) 3. **Cherry-pick valuable commits:** ```bash git checkout -b upstream-sync/vertex-improvements git cherry-pick 001019c # style fixes git cherry-pick e4f1e4c # vertex token cost git cherry-pick 202efde # vertex fix git cherry-pick 0c2f888 # vertex docs git cherry-pick 3c4ca30 # vertex tracking # Skip Augment removal commits! ``` ### Immediate (Phase 2) 1. **Replace debug prints with proper logging** - Use `CodexBarLog.logger("augment")` pattern - Add structured metadata - Follow Claude/Cursor provider patterns 2. **Enhanced cookie diagnostics** - Log cookie expiration times - Track refresh attempts - Add domain filtering diagnostics 3. **Session keepalive monitoring** - Add keepalive status to debug pane - Log refresh attempts - Add manual "Force Refresh" button ### Short Term (Phases 3-4) - **Analyze Quotio features** using `./Scripts/analyze_quotio.sh` - **Regular upstream monitoring** (automated via GitHub Actions) - **Weekly sync routine** (Monday: upstream, Thursday: quotio) ### Medium Term (Phase 5) - Implement multi-account management (inspired by quotio) - Start with Augment provider - Extend to other providers --- ## 📁 Key Files Modified ### Source Code - `Sources/CodexBar/About.swift` - Dual attribution - `Sources/CodexBar/PreferencesAboutPane.swift` - Organized sections - `Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift` - Debug prints (unstaged) ### Documentation - `README.md` - Fork notice and enhancements - `docs/augment.md` - Augment provider guide (NEW) - `docs/FORK_ROADMAP.md` - Development roadmap (NEW) - `docs/FORK_QUICK_START.md` - Quick reference (NEW) --- ## 🔄 Git Status ```bash # Current branch feature/augment-integration # Commits ahead of main 4 commits: - da3d13e: Fork identity with dual attribution - 745293e: Roadmap and quick start guide - 8a87473: Fork status tracking - df75ae2: Multi-upstream management system # Uncommitted changes M Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift (debug prints) # Git remotes configured origin git@github.com:topoffunnel/CodexBar.git upstream https://github.com/steipete/CodexBar.git (needs to be added) quotio https://github.com/nguyenphutrong/quotio.git (needs to be added) ``` --- ## 🚀 How to Continue ### RECOMMENDED: Setup Multi-Upstream System First ```bash # 1. Configure git remotes git remote add upstream https://github.com/steipete/CodexBar.git git remote add quotio https://github.com/nguyenphutrong/quotio.git git fetch --all # 2. Test automation scripts ./Scripts/check_upstreams.sh # 3. Review upstream changes (IMPORTANT!) ./Scripts/review_upstream.sh upstream # 4. Decide what to sync # See "URGENT: Upstream Sync Decision" section above # 5. Analyze quotio ./Scripts/analyze_quotio.sh ``` ### Option 1: Sync Upstream First, Then Phase 2 ```bash # Discard debug prints (will redo in Phase 2) git checkout Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift # Sync valuable upstream changes git checkout -b upstream-sync/vertex-improvements # Cherry-pick commits (see URGENT section) # Merge to main git checkout main git merge feature/augment-integration git merge upstream-sync/vertex-improvements # Then start Phase 2 git checkout -b feature/augment-diagnostics ``` ### Option 2: Phase 2 First, Sync Later ```bash # Keep debug prints and enhance them git add Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift # Continue on current branch # Replace print() with CodexBarLog.logger("augment") # Complete Phase 2 # Then sync upstream ``` ### Option 3: Merge Current Work, Setup System ```bash # Discard debug prints git checkout Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift # Merge to main git checkout main git merge feature/augment-integration # Setup remotes git remote add upstream https://github.com/steipete/CodexBar.git git remote add quotio https://github.com/nguyenphutrong/quotio.git # Start using the system ./Scripts/check_upstreams.sh ``` --- ## 📊 Progress Tracking ### Phase 1: Fork Identity ✅ COMPLETE - [x] Dual attribution in About - [x] Fork notice in README - [x] Augment documentation - [x] Development roadmap - [x] Quick start guide ### Phase 2: Enhanced Diagnostics 🔄 READY TO START - [ ] Replace print() with CodexBarLog - [ ] Enhanced cookie diagnostics - [ ] Session keepalive monitoring - [ ] Debug pane improvements ### Phase 3: Quotio Analysis 📋 PLANNED - [ ] Feature comparison matrix - [ ] Implementation recommendations - [ ] Priority ranking ### Phase 4: Upstream Sync 📋 PLANNED - [ ] Sync script - [ ] Conflict resolution guide - [ ] Automated checks ### Phase 5: Multi-Account 📋 PLANNED - [ ] Account management UI - [ ] Account storage - [ ] Account switching - [ ] UI enhancements --- ## 🎯 Success Criteria ### Phase 1 (Current) ✅ - [x] Fork identity clearly established - [x] Original author properly credited - [x] Comprehensive documentation - [x] App builds and runs - [x] No regressions ### Phase 2 (Next) - [ ] Zero cookie disconnection issues - [ ] Proper structured logging - [ ] Enhanced debug diagnostics - [ ] Manual refresh capability - [ ] All tests passing --- ## 📞 Questions & Decisions Needed ### Before Starting Phase 2 1. **Logging approach:** Keep debug prints and enhance, or start fresh? 2. **Branch strategy:** Continue on `feature/augment-integration` or create new branch? 3. **Merge timing:** Merge Phase 1 to main first, or continue with all phases? ### For Phase 3 1. **Quotio access:** Do you have access to Quotio source code? 2. **Feature priority:** Which Quotio features are most important? 3. **Timeline:** How much time to allocate for analysis? ### For Phase 5 1. **Account limit:** How many accounts per provider? 2. **UI design:** Menu bar dropdown or separate window? 3. **Storage:** Keychain per account or shared? --- ## 🔗 Quick Links - **Roadmap:** `docs/FORK_ROADMAP.md` - **Quick Start:** `docs/FORK_QUICK_START.md` - **Augment Docs:** `docs/augment.md` - **Original Repo:** https://github.com/steipete/CodexBar - **Fork Repo:** https://github.com/topoffunnel/CodexBar --- ## 💡 Recommendations 1. **Merge Phase 1 to main** - Establish fork identity as baseline 2. **Create Phase 2 branch** - `feature/augment-diagnostics` 3. **Start with logging** - Replace prints with proper CodexBarLog 4. **Test thoroughly** - Ensure no regressions 5. **Document as you go** - Update docs with findings --- **Ready to proceed with Phase 2?** See `docs/FORK_ROADMAP.md` for detailed tasks. ================================================ FILE: IMPLEMENTATION_SUMMARY.md ================================================ # CodexBar Fork - Implementation Summary **Date:** January 4, 2026 **Implementer:** Augment AI Assistant **For:** Brandon Charleson (topoffunnel.com) --- ## 🎉 What Was Accomplished ### Phase 1: Fork Identity & Credits ✅ COMPLETE **Objective:** Establish clear fork identity while properly crediting original author **Deliverables:** 1. **Dual Attribution System** - Updated `About.swift` with original author + fork maintainer - Updated `PreferencesAboutPane.swift` with organized sections - App icon click now opens fork repository - Clear separation of original vs fork contributions 2. **Documentation Suite** - `docs/augment.md` - Comprehensive Augment provider guide (150+ lines) - `docs/FORK_ROADMAP.md` - 5-phase development plan - `docs/FORK_QUICK_START.md` - Developer quick reference - `FORK_STATUS.md` - Living status tracker 3. **README Updates** - Fork notice at top with link to original - "Fork Enhancements" section documenting improvements - Updated credits with dual attribution - Clear differentiation from original **Result:** Fork has professional identity, ready for distribution via topoffunnel.com --- ### Multi-Upstream Management System ✅ COMPLETE **Objective:** Monitor and selectively incorporate changes from two upstream repositories **Deliverables:** #### 1. Automation Scripts (4 scripts, all executable) **`Scripts/check_upstreams.sh`** - Monitors both upstream and quotio for new commits - Shows commit summaries and file changes - Color-coded output for easy scanning - Usage: `./Scripts/check_upstreams.sh [upstream|quotio|all]` **`Scripts/review_upstream.sh`** - Creates review branch for upstream changes - Shows detailed commit log and diffs - Generates review log file - Usage: `./Scripts/review_upstream.sh [upstream|quotio]` **`Scripts/prepare_upstream_pr.sh`** - Creates clean branch from upstream/main for PR submission - Provides guidelines for what to include/exclude - Prevents fork branding from going upstream - Usage: `./Scripts/prepare_upstream_pr.sh ` **`Scripts/analyze_quotio.sh`** - Analyzes quotio repository structure and recent changes - Generates analysis report with action items - Helps identify patterns to adapt (not copy) - Usage: `./Scripts/analyze_quotio.sh [feature-area]` #### 2. GitHub Actions Workflow **`.github/workflows/upstream-monitor.yml`** - Runs Monday and Thursday at 9 AM UTC - Checks both upstreams for new commits - Creates/updates GitHub issue with summaries - Provides links to review changes - Can be triggered manually #### 3. Comprehensive Documentation (3 guides) **`docs/UPSTREAM_STRATEGY.md`** (630+ lines) - Complete multi-upstream management guide - Git repository structure and remote configuration - Workflows for monitoring, reviewing, incorporating changes - Decision matrix: what to contribute upstream vs keep in fork - Commit message strategies and attribution - Practical examples and troubleshooting - Best practices and success metrics **`docs/QUOTIO_ANALYSIS.md`** (150+ lines) - Framework for learning from quotio patterns - Ethical guidelines (adapt patterns, don't copy code) - Analysis process and templates - Feature comparison matrix - Implementation planning - Legal and attribution considerations **`docs/FORK_SETUP.md`** (150+ lines) - One-time setup guide for git remotes - Script testing and verification - Critical discovery documentation - Selective sync strategy - Regular workflow recommendations --- ## 🚨 Critical Discovery **Upstream (steipete) has REMOVED the Augment provider!** **Evidence:** ``` Files changed in upstream: .../Providers/Augment/AugmentStatusProbe.swift | 627 deletions Tests/CodexBarTests/AugmentStatusProbeTests.swift | 88 deletions ``` **Impact:** - ✅ **Validates fork strategy** - We preserve features important to our users - ✅ **Justifies independent development** - Can't rely on upstream for Augment - ✅ **Enables selective sync** - Cherry-pick valuable changes, skip Augment removal - ✅ **Protects user experience** - Fork users keep Augment functionality **Action Required:** When syncing with upstream, must cherry-pick commits selectively to avoid losing Augment support. --- ## 📊 Commits Summary **Total Commits:** 5 1. `da3d13e` - Fork identity with dual attribution 2. `745293e` - Roadmap and quick start guide 3. `8a87473` - Fork status tracking document 4. `df75ae2` - Multi-upstream management system 5. `158d00c` - Updated fork status **Lines Added:** ~2,500+ lines of documentation and automation **Files Created:** 11 new files **Scripts Created:** 4 executable automation scripts **Workflows Created:** 1 GitHub Actions workflow --- ## 🎯 Strategic Benefits ### For Fork Development 1. **Independence** - Can develop features without upstream dependency 2. **Selective Sync** - Cherry-pick valuable improvements, skip unwanted changes 3. **Attribution Protection** - Fork-specific commits stay separate 4. **User Focus** - Preserve features important to your users (Augment) ### For Upstream Relationship 1. **Contribution Ready** - Clean PR branches for upstream submissions 2. **Good Citizenship** - Can contribute bug fixes and improvements 3. **Proper Credit** - Attribution system respects original author 4. **Flexibility** - Option to contribute or keep changes in fork ### For Learning from Quotio 1. **Ethical Framework** - Clear guidelines for pattern analysis 2. **Legal Protection** - Adapt patterns, don't copy code 3. **Innovation** - Learn from their solutions, implement independently 4. **Attribution** - Credit inspiration appropriately --- ## 📋 Current State ### What's Ready - ✅ Fork identity established - ✅ Comprehensive documentation - ✅ Automation scripts tested and working - ✅ GitHub Actions workflow configured - ✅ Git remotes documented (need to be added) - ✅ Selective sync strategy defined - ✅ App builds and runs successfully ### What's Pending - ⏳ Git remotes need to be added (one-time setup) - ⏳ Upstream sync decision needed (5 new commits available) - ⏳ Quotio analysis to be performed - ⏳ Phase 2 (Enhanced Augment diagnostics) ### Known Issues - ⚠️ Augment cookie disconnection (Phase 2 will address) - ⚠️ Debug print statements in AugmentStatusProbe.swift (unstaged) --- ## 🚀 Next Steps for You ### Immediate (Before Phase 2) **1. Setup Git Remotes** ```bash git remote add upstream https://github.com/steipete/CodexBar.git git remote add quotio https://github.com/nguyenphutrong/quotio.git git fetch --all ``` **2. Test Automation** ```bash ./Scripts/check_upstreams.sh ./Scripts/review_upstream.sh upstream ./Scripts/analyze_quotio.sh ``` **3. Decide on Upstream Sync** - Review 5 new upstream commits - Cherry-pick valuable changes (Vertex AI improvements) - Skip Augment removal commits - See `FORK_STATUS.md` for detailed instructions ### Short Term (This Week) **4. Merge to Main** ```bash git checkout main git merge feature/augment-integration ``` **5. Enable GitHub Actions** - Push to your fork - Enable Actions in repository settings - Verify workflow runs **6. Start Regular Monitoring** - Monday: Check upstream (`./Scripts/check_upstreams.sh upstream`) - Thursday: Analyze quotio (`./Scripts/analyze_quotio.sh`) ### Medium Term (Next 2 Weeks) **7. Complete Phase 2** - Enhanced Augment diagnostics - Proper logging with CodexBarLog - Session keepalive monitoring **8. Quotio Analysis** - Document multi-account patterns - Plan implementation - Prioritize features --- ## 📖 Documentation Index ### Core Documents - `README.md` - Main documentation with fork notice - `FORK_STATUS.md` - Current status and next steps - `IMPLEMENTATION_SUMMARY.md` - This document ### Setup & Strategy - `docs/FORK_SETUP.md` - One-time setup guide - `docs/FORK_QUICK_START.md` - Developer quick reference - `docs/UPSTREAM_STRATEGY.md` - Multi-upstream management - `docs/FORK_ROADMAP.md` - 5-phase development plan ### Provider & Analysis - `docs/augment.md` - Augment provider guide - `docs/QUOTIO_ANALYSIS.md` - Quotio pattern analysis framework ### Scripts - `Scripts/check_upstreams.sh` - Monitor upstreams - `Scripts/review_upstream.sh` - Review changes - `Scripts/prepare_upstream_pr.sh` - Prepare PRs - `Scripts/analyze_quotio.sh` - Analyze quotio --- ## 💡 Key Insights 1. **Fork Validation** - Upstream removing Augment proves fork was necessary 2. **Best of Both Worlds** - Can learn from two sources while maintaining independence 3. **Selective Sync** - Cherry-picking gives control over what changes to adopt 4. **Attribution Matters** - Separate commits protect your contributions 5. **Automation Wins** - Scripts and workflows reduce manual effort --- ## ✅ Success Criteria Met - [x] Fork identity clearly established - [x] Original author properly credited - [x] Comprehensive documentation - [x] Multi-upstream monitoring system - [x] Automation scripts working - [x] GitHub Actions configured - [x] Selective sync strategy defined - [x] App builds and runs - [x] No regressions --- **Status:** Phase 1 COMPLETE + Multi-Upstream System OPERATIONAL **Ready for:** Upstream sync decision + Phase 2 development **Recommendation:** Setup remotes, sync upstream, then proceed to Phase 2 ================================================ FILE: Icon.icon/icon.json ================================================ { "fill" : { "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" }, "groups" : [ { "layers" : [ { "image-name" : "codexbar.png", "name" : "codexbar", "position" : { "scale" : 1.4, "translation-in-points" : [ 0, 0 ] } } ], "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } } ], "supported-platforms" : { "circles" : [ "watchOS" ], "squares" : "shared" } } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 Peter Steinberger 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. ================================================ FILE: Package.resolved ================================================ { "originHash" : "74bd6f3ab6e0b0cb0c2cddb00f2167c2ab0a1c00cd54ffc1a2899c7ef8c56367", "pins" : [ { "identity" : "commander", "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Commander", "state" : { "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", "version" : "0.2.1" } }, { "identity" : "keyboardshortcuts", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/KeyboardShortcuts", "state" : { "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", "version" : "2.4.0" } }, { "identity" : "sparkle", "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", "version" : "2.8.1" } }, { "identity" : "sweetcookiekit", "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/SweetCookieKit", "state" : { "revision" : "4d5b71ffbb296937dc5ee8472f64721bca771cf0", "version" : "0.4.0" } }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", "version" : "1.9.1" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } } ], "version" : 3 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.2 import CompilerPluginSupport import Foundation import PackageDescription let sweetCookieKitPath = "../SweetCookieKit" let useLocalSweetCookieKit = ProcessInfo.processInfo.environment["CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT"] == "1" let sweetCookieKitDependency: Package.Dependency = useLocalSweetCookieKit && FileManager.default.fileExists(atPath: sweetCookieKitPath) ? .package(path: sweetCookieKitPath) : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.0") let package = Package( name: "CodexBar", platforms: [ .macOS(.v14), ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), .package(url: "https://github.com/apple/swift-log", from: "1.9.1"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), sweetCookieKitDependency, ], targets: { var targets: [Target] = [ .target( name: "CodexBarCore", dependencies: [ "CodexBarMacroSupport", .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), .macro( name: "CodexBarMacros", dependencies: [ .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), ]), .target( name: "CodexBarMacroSupport", dependencies: [ "CodexBarMacros", ]), .executableTarget( name: "CodexBarCLI", dependencies: [ "CodexBarCore", .product(name: "Commander", package: "Commander"), ], path: "Sources/CodexBarCLI", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), .testTarget( name: "CodexBarLinuxTests", dependencies: ["CodexBarCore", "CodexBarCLI"], path: "TestsLinux", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), .enableExperimentalFeature("SwiftTesting"), ]), ] #if os(macOS) targets.append(contentsOf: [ .executableTarget( name: "CodexBarClaudeWatchdog", dependencies: [], path: "Sources/CodexBarClaudeWatchdog", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), .executableTarget( name: "CodexBar", dependencies: [ .product(name: "Sparkle", package: "Sparkle"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), "CodexBarMacroSupport", "CodexBarCore", ], path: "Sources/CodexBar", resources: [ .process("Resources"), ], swiftSettings: [ // Opt into Swift 6 strict concurrency (approachable migration path). .enableUpcomingFeature("StrictConcurrency"), .define("ENABLE_SPARKLE"), ]), .executableTarget( name: "CodexBarWidget", dependencies: ["CodexBarCore"], path: "Sources/CodexBarWidget", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), .executableTarget( name: "CodexBarClaudeWebProbe", dependencies: ["CodexBarCore"], path: "Sources/CodexBarClaudeWebProbe", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), ]) targets.append(.testTarget( name: "CodexBarTests", dependencies: ["CodexBar", "CodexBarCore", "CodexBarCLI", "CodexBarWidget"], path: "Tests", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), .enableExperimentalFeature("SwiftTesting"), ])) #endif return targets }()) ================================================ FILE: README.md ================================================ # CodexBar 🎚️ - May your tokens never run out. Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot ## Install ### Requirements - macOS 14+ (Sonoma) ### GitHub Releases Download: ### Homebrew ```bash brew install --cask steipete/tap/codexbar ``` ### Linux (CLI only) ```bash brew install steipete/tap/codexbar ``` Or download `CodexBarCLI-v-linux-.tar.gz` from GitHub Releases. Linux support via Omarchy: community Waybar module and TUI, driven by the `codexbar` executable. ### First run - Open Settings → Providers and enable what you use. - Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running). - Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras. ## Providers - [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras. - [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. - [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals. - [Kiro](docs/kiro.md) — CLI-based usage via `kiro-cli /usage` command; monthly credits + bonus credits. - [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs. - [Augment](docs/augment.md) — Browser cookie-based authentication with automatic session keepalive; credits tracking and usage monitoring. - [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking. - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot The menu bar icon is a tiny two-bar meter: - Top bar: 5‑hour/session window. If weekly is missing/exhausted and credits are available, it becomes a thicker credits bar. - Bottom bar: weekly window (hairline). - Errors/stale data dim the icon; status overlays indicate incidents. ## Features - Multi-provider menu bar with per-provider toggles (Settings → Providers). - Session + weekly meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Local cost-usage scan for Codex + Claude (last 30 days). - Provider status polling with incident badges in the menu and icon overlay. - Merge Icons mode to combine providers into one status item + switcher, with an optional Overview tab for up to three providers. - Refresh cadence presets (manual, 1m, 2m, 5m, 15m). - Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available. - WidgetKit widget mirrors the menu card snapshot. - Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored). ## Privacy note Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, local JSONL logs) when the related features are enabled. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12). ## macOS permissions (why they’re needed) - **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead. - **Keychain access (prompted by macOS)**: - Chrome cookie import needs the “Chrome Safe Storage” key to decrypt cookies. - Claude OAuth credentials (written by the Claude CLI) are read from Keychain when present. - z.ai API token is stored in Keychain from Preferences → Providers; Copilot stores its API token in Keychain during device flow. - **How do I prevent those keychain alerts?** - Open **Keychain Access.app** → login keychain → search the item (e.g., “Claude Code-credentials”). - Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”. - Prefer adding just CodexBar (avoid “Allow all applications” unless you want it wide open). - Relaunch CodexBar after saving. - Reference screenshot: ![Keychain access control](docs/keychain-allow.png) - **How to do the same for the browser?** - Find the browser’s “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Firefox”, “Microsoft Edge Safe Storage”). - Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”. - This removes the prompt when CodexBar decrypts cookies for that browser. - **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs (codex/claude/gemini/antigravity). If those CLIs read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the CLI’s working directory, not background disk scanning. - **What we do not request**: no Screen Recording, Accessibility, or Automation permissions; no passwords are stored (browser cookies are reused when you opt in). ## Docs - Providers overview: [docs/providers.md](docs/providers.md) - Provider authoring: [docs/provider.md](docs/provider.md) - UI & icon notes: [docs/ui.md](docs/ui.md) - CLI reference: [docs/cli.md](docs/cli.md) - Architecture: [docs/architecture.md](docs/architecture.md) - Refresh loop: [docs/refresh-loop.md](docs/refresh-loop.md) - Status polling: [docs/status.md](docs/status.md) - Sparkle updates: [docs/sparkle.md](docs/sparkle.md) - Release checklist: [docs/RELEASING.md](docs/RELEASING.md) ## Getting started (dev) - Clone the repo and open it in Xcode or run the scripts directly. - Launch once, then toggle providers in Settings → Providers. - Install/sign in to provider sources you rely on (CLIs, browser cookies, or OAuth). - Optional: set OpenAI cookies (Automatic or Manual) for Codex dashboard extras. ## Build from source ```bash swift build -c release # or debug for development ./Scripts/package_app.sh # builds CodexBar.app in-place CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh # ad-hoc signing (no Apple Developer account) open CodexBar.app ``` Dev loop: ```bash ./Scripts/compile_and_run.sh ``` ## Related - ✂️ [Trimmy](https://github.com/steipete/Trimmy) — “Paste once, run once.” Flatten multi-line shell snippets so they paste and run. - 🧳 [MCPorter](https://mcporter.dev) — TypeScript toolkit + CLI for Model Context Protocol servers. - 🧿 [oracle](https://askoracle.dev) — Ask the oracle when you're stuck. Invoke GPT-5 Pro with a custom context and files. ## Looking for a Windows version? - [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar) ## Credits Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking. ## License MIT • Peter Steinberger ([steipete](https://twitter.com/steipete)) ================================================ FILE: Scripts/analyze_quotio.sh ================================================ #!/bin/bash # Analyze quotio repository for interesting patterns and features # Usage: ./Scripts/analyze_quotio.sh [feature-area] set -e AREA=${1:-all} # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' echo -e "${BLUE}==> Fetching latest quotio...${NC}" git fetch quotio 2>/dev/null || { echo -e "${YELLOW}Adding quotio remote...${NC}" git remote add quotio https://github.com/nguyenphutrong/quotio.git git fetch quotio } echo "" echo -e "${GREEN}==> Quotio Repository Analysis${NC}" echo "" # Show recent activity echo -e "${BLUE}Recent Activity (last 30 days):${NC}" git log --oneline --graph --remotes=quotio/main --since="30 days ago" | head -20 echo "" # Analyze file structure echo -e "${BLUE}File Structure:${NC}" git ls-tree -r --name-only quotio/main | grep -E '\.(swift|md)$' | head -30 echo "" # Find interesting patterns based on area case $AREA in "providers"|"all") echo -e "${BLUE}Provider Implementations:${NC}" git ls-tree -r --name-only quotio/main | grep -i provider | head -20 echo "" ;; esac case $AREA in "ui"|"all") echo -e "${BLUE}UI Components:${NC}" git ls-tree -r --name-only quotio/main | grep -iE '(view|ui|menu)' | head -20 echo "" ;; esac case $AREA in "auth"|"all") echo -e "${BLUE}Authentication/Session:${NC}" git ls-tree -r --name-only quotio/main | grep -iE '(auth|session|cookie|login)' | head -20 echo "" ;; esac # Show commit messages for pattern analysis echo -e "${BLUE}Recent Commit Messages (for pattern analysis):${NC}" git log --oneline quotio/main --since="60 days ago" | head -30 echo "" # Create analysis report REPORT_FILE="quotio-analysis-$(date +%Y%m%d).md" cat > "$REPORT_FILE" << EOF # Quotio Analysis Report **Date:** $(date +%Y-%m-%d) **Purpose:** Identify patterns and features for CodexBar fork inspiration ## Recent Activity \`\`\` $(git log --oneline --graph --remotes=quotio/main --since="30 days ago" | head -20) \`\`\` ## File Structure \`\`\` $(git ls-tree -r --name-only quotio/main | grep -E '\.(swift|md)$' | head -50) \`\`\` ## Recent Commits \`\`\` $(git log --oneline quotio/main --since="60 days ago" | head -30) \`\`\` ## Areas of Interest ### Providers - [ ] Review provider implementations - [ ] Compare with CodexBar approach - [ ] Identify improvements ### UI/UX - [ ] Menu bar organization - [ ] Settings layout - [ ] Status indicators ### Authentication - [ ] Session management - [ ] Cookie handling - [ ] OAuth flows ### Multi-Account - [ ] Account switching - [ ] Account storage - [ ] UI patterns ## Action Items - [ ] Review specific files of interest - [ ] Document patterns (not code) - [ ] Create implementation plan - [ ] Implement independently ## Notes Remember: We're looking for PATTERNS and IDEAS, not copying code. All implementations must be original and follow CodexBar conventions. EOF echo -e "${GREEN}Analysis report saved to: $REPORT_FILE${NC}" echo "" echo -e "${YELLOW}Next steps:${NC}" echo "" echo "1. View specific files:" echo " ${GREEN}git show quotio/main:path/to/file${NC}" echo "" echo "2. Compare implementations:" echo " ${GREEN}git diff main quotio/main -- path/to/similar/file${NC}" echo "" echo "3. Review commit details:" echo " ${GREEN}git log -p quotio/main --since='30 days ago'${NC}" echo "" echo "4. Document patterns in:" echo " ${GREEN}docs/QUOTIO_ANALYSIS.md${NC}" echo "" echo -e "${BLUE}Remember: Adapt patterns, don't copy code!${NC}" ================================================ FILE: Scripts/build_icon.sh ================================================ #!/usr/bin/env bash set -euo pipefail ICON_FILE=${1:-Icon.icon} BASENAME=${2:-Icon} OUT_ROOT=${3:-build/icon} XCODE_APP=${XCODE_APP:-/Applications/Xcode.app} ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool" if [[ ! -x "$ICTOOL" ]]; then ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool" fi if [[ ! -x "$ICTOOL" ]]; then echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2 exit 1 fi ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset" TMP_DIR="$OUT_ROOT/tmp" mkdir -p "$ICONSET_DIR" "$TMP_DIR" MASTER_ART="$TMP_DIR/icon_art_824.png" MASTER_1024="$TMP_DIR/icon_1024.png" # Render inner art (no margin) with macOS Default appearance "$ICTOOL" "$ICON_FILE" \ --export-preview macOS Default 824 824 1 -45 "$MASTER_ART" # Pad to 1024x1024 with transparent border sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null # Generate required sizes sizes=(16 32 64 128 256 512 1024) for sz in "${sizes[@]}"; do out="$ICONSET_DIR/icon_${sz}x${sz}.png" sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null if [[ "$sz" -ne 1024 ]]; then dbl=$((sz*2)) out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png" sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null fi done # 512x512@2x already covered by 1024; ensure it exists cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png" iconutil -c icns "$ICONSET_DIR" -o Icon.icns echo "Icon.icns generated at $(pwd)/Icon.icns" ================================================ FILE: Scripts/changelog-to-html.sh ================================================ #!/usr/bin/env bash set -euo pipefail VERSION=${1:-} CHANGELOG_FILE=${2:-} if [[ -z "$VERSION" ]]; then echo "Usage: $0 [changelog_file]" >&2 exit 1 fi SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) if [[ -z "$CHANGELOG_FILE" ]]; then if [[ -f "$SCRIPT_DIR/../CHANGELOG.md" ]]; then CHANGELOG_FILE="$SCRIPT_DIR/../CHANGELOG.md" elif [[ -f "CHANGELOG.md" ]]; then CHANGELOG_FILE="CHANGELOG.md" elif [[ -f "../CHANGELOG.md" ]]; then CHANGELOG_FILE="../CHANGELOG.md" else echo "Error: Could not find CHANGELOG.md" >&2 exit 1 fi fi if [[ ! -f "$CHANGELOG_FILE" ]]; then echo "Error: Changelog file '$CHANGELOG_FILE' not found" >&2 exit 1 fi extract_version_section() { local version=$1 local file=$2 awk -v version="$version" ' BEGIN { found=0 } /^## / { if ($0 ~ "^##[[:space:]]+" version "([[:space:]].*|$)") { found=1; next } if (found) { exit } } found { print } ' "$file" } markdown_to_html() { local text=$1 text=$(echo "$text" | sed 's/^### \(.*\)$/

\1<\/h3>/') text=$(echo "$text" | sed 's/^## \(.*\)$/

\1<\/h2>/') text=$(echo "$text" | sed 's/^- \*\*\([^*]*\)\*\*\(.*\)$/
  • \1<\/strong>\2<\/li>/') text=$(echo "$text" | sed 's/^- \([^*].*\)$/
  • \1<\/li>/') text=$(echo "$text" | sed 's/\*\*\([^*]*\)\*\*/\1<\/strong>/g') text=$(echo "$text" | sed 's/`\([^`]*\)`/\1<\/code>/g') text=$(echo "$text" | sed 's/\[\([^]]*\)\](\([^)]*\))/\1<\/a>/g') echo "$text" } version_content=$(extract_version_section "$VERSION" "$CHANGELOG_FILE") if [[ -z "$version_content" ]]; then echo "

    CodexBar $VERSION

    " echo "

    Latest CodexBar update.

    " echo "

    View full changelog

    " exit 0 fi echo "

    CodexBar $VERSION

    " in_list=false while IFS= read -r line; do if [[ "$line" =~ ^- ]]; then if [[ "$in_list" == false ]]; then echo "
      " in_list=true fi markdown_to_html "$line" else if [[ "$in_list" == true ]]; then echo "
    " in_list=false fi if [[ -n "$line" ]]; then markdown_to_html "$line" fi fi done <<< "$version_content" if [[ "$in_list" == true ]]; then echo "" fi echo "

    View full changelog

    " ================================================ FILE: Scripts/check-release-assets.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) source "$HOME/Projects/agent-scripts/release/sparkle_lib.sh" TAG=${1:-$(git describe --tags --abbrev=0)} ARTIFACT_PREFIX="CodexBar-" check_assets "$TAG" "$ARTIFACT_PREFIX" ================================================ FILE: Scripts/check_upstreams.sh ================================================ #!/bin/bash # Check for new changes in upstream repositories # Usage: ./Scripts/check_upstreams.sh [upstream|quotio|all] set -e TARGET=${1:-all} DAYS=${2:-7} # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color echo -e "${BLUE}==> Fetching upstream changes...${NC}" if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then git fetch upstream 2>/dev/null || { echo -e "${YELLOW}Adding upstream remote...${NC}" git remote add upstream https://github.com/steipete/CodexBar.git git fetch upstream } fi if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then git fetch quotio 2>/dev/null || { echo -e "${YELLOW}Adding quotio remote...${NC}" git remote add quotio https://github.com/nguyenphutrong/quotio.git git fetch quotio } fi echo "" # Check upstream (steipete) if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}" UPSTREAM_COUNT=$(git log --oneline main..upstream/main --no-merges 2>/dev/null | wc -l | tr -d ' ') if [ "$UPSTREAM_COUNT" -gt 0 ]; then echo -e "${GREEN}Found $UPSTREAM_COUNT new commits${NC}" echo "" git log --oneline --graph main..upstream/main --no-merges | head -20 echo "" echo -e "${YELLOW}Files changed:${NC}" git diff --stat main..upstream/main | tail -20 else echo -e "${GREEN}No new commits (up to date)${NC}" fi echo "" fi # Check quotio if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then echo -e "${BLUE}==> Quotio changes (last $DAYS days):${NC}" QUOTIO_COUNT=$(git log --oneline --all --remotes=quotio/main --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ') if [ "$QUOTIO_COUNT" -gt 0 ]; then echo -e "${GREEN}Found $QUOTIO_COUNT commits in last $DAYS days${NC}" echo "" git log --oneline --graph --remotes=quotio/main --since="$DAYS days ago" | head -20 echo "" echo -e "${YELLOW}Recent file changes:${NC}" # Show changes from last 10 commits git diff --stat quotio/main~10..quotio/main 2>/dev/null | tail -20 || echo "Unable to show diff" else echo -e "${GREEN}No new commits in last $DAYS days${NC}" fi echo "" fi # Summary echo -e "${BLUE}==> Summary${NC}" if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then echo "Upstream commits: $UPSTREAM_COUNT" fi if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then echo "Quotio commits (${DAYS}d): $QUOTIO_COUNT" fi echo "" echo -e "${YELLOW}Next steps:${NC}" echo " Review upstream: ./Scripts/review_upstream.sh upstream" echo " Review quotio: ./Scripts/review_upstream.sh quotio" echo " Detailed diff: git diff main..upstream/main" echo " View quotio: git log -p quotio/main~10..quotio/main" ================================================ FILE: Scripts/compile_and_run.sh ================================================ #!/usr/bin/env bash # Reset CodexBar: kill running instances, build, package, relaunch, verify. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_BUNDLE="${ROOT_DIR}/CodexBar.app" APP_PROCESS_PATTERN="CodexBar.app/Contents/MacOS/CodexBar" DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/CodexBar" RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/CodexBar" LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)" LOCK_DIR="${TMPDIR:-/tmp}/codexbar-compile-and-run-${LOCK_KEY}" LOCK_PID_FILE="${LOCK_DIR}/pid" WAIT_FOR_LOCK=0 RUN_TESTS=0 DEBUG_LLDB=0 RELEASE_ARCHES="" SIGNING_MODE="${CODEXBAR_SIGNING:-}" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } has_signing_identity() { local identity="${1:-}" if [[ -z "${identity}" ]]; then return 1 fi security find-identity -p codesigning -v 2>/dev/null | grep -F "${identity}" >/dev/null 2>&1 } resolve_signing_mode() { if [[ -n "${SIGNING_MODE}" ]]; then return fi if [[ -n "${APP_IDENTITY:-}" ]]; then if has_signing_identity "${APP_IDENTITY}"; then SIGNING_MODE="identity" return fi log "WARN: APP_IDENTITY not found in Keychain; falling back to adhoc signing." SIGNING_MODE="adhoc" return fi local candidate="" for candidate in \ "Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \ "CodexBar Development" do if has_signing_identity "${candidate}"; then APP_IDENTITY="${candidate}" export APP_IDENTITY SIGNING_MODE="identity" return fi done SIGNING_MODE="adhoc" } run_step() { local label="$1"; shift log "==> ${label}" if ! "$@"; then fail "${label} failed" fi } cleanup() { if [[ -d "${LOCK_DIR}" ]]; then rm -rf "${LOCK_DIR}" fi } acquire_lock() { while true; do if mkdir "${LOCK_DIR}" 2>/dev/null; then echo "$$" > "${LOCK_PID_FILE}" return 0 fi local existing_pid="" if [[ -f "${LOCK_PID_FILE}" ]]; then existing_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)" fi if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then if [[ "${WAIT_FOR_LOCK}" == "1" ]]; then log "==> Another agent is compiling (pid ${existing_pid}); waiting..." while kill -0 "${existing_pid}" 2>/dev/null; do sleep 1 done continue fi log "==> Another agent is compiling (pid ${existing_pid}); re-run with --wait." exit 0 fi rm -rf "${LOCK_DIR}" done } trap cleanup EXIT INT TERM kill_claude_probes() { # CodexBar spawns `claude /usage` + `/status` in a PTY; if we kill the app mid-probe we can orphan them. pkill -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true sleep 0.2 pkill -9 -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true } kill_all_codexbar() { is_running() { pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \ || pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \ || pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \ || pgrep -x "CodexBar" >/dev/null 2>&1 } # Phase 1: request termination (give the app time to exit cleanly). for _ in {1..25}; do pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true pkill -x "CodexBar" 2>/dev/null || true if ! is_running; then return 0 fi sleep 0.2 done # Phase 2: force kill any stragglers (avoids `open -n` creating multiple instances). pkill -9 -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true pkill -9 -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true pkill -9 -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true pkill -9 -x "CodexBar" 2>/dev/null || true for _ in {1..25}; do if ! is_running; then return 0 fi sleep 0.2 done fail "Failed to kill all CodexBar instances." } # 1) Ensure a single runner instance. for arg in "$@"; do case "${arg}" in --wait|-w) WAIT_FOR_LOCK=1 ;; --test|-t) RUN_TESTS=1 ;; --debug-lldb) DEBUG_LLDB=1 ;; --release-universal) RELEASE_ARCHES="arm64 x86_64" ;; --release-arches=*) RELEASE_ARCHES="${arg#*=}" ;; --help|-h) log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--release-universal] [--release-arches=\"arm64 x86_64\"]" exit 0 ;; *) ;; esac done resolve_signing_mode if [[ "${SIGNING_MODE}" == "adhoc" ]]; then log "==> Signing: adhoc (set APP_IDENTITY or install a dev cert to avoid keychain prompts)" else log "==> Signing: ${APP_IDENTITY:-Developer ID Application}" fi acquire_lock # 2) Kill all running CodexBar instances (debug, release, bundled). log "==> Killing existing CodexBar instances" kill_all_codexbar kill_claude_probes # 2.5) Delete keychain entries to avoid permission prompts with adhoc signing # (adhoc signature changes on every build, making old keychain entries inaccessible) if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then log "==> Clearing keychain entries (adhoc signing)" security delete-generic-password -s "com.steipete.CodexBar" 2>/dev/null || true # Clear all keychain items for the app to avoid multiple prompts while security delete-generic-password -s "com.steipete.CodexBar" 2>/dev/null; do : done fi # 3) Package (release build happens inside package_app.sh). if [[ "${RUN_TESTS}" == "1" ]]; then run_step "swift test" swift test -q fi if [[ "${DEBUG_LLDB}" == "1" && -n "${RELEASE_ARCHES}" ]]; then fail "--release-arches is only supported for release packaging" fi HOST_ARCH="$(uname -m)" ARCHES_VALUE="${HOST_ARCH}" if [[ -n "${RELEASE_ARCHES}" ]]; then ARCHES_VALUE="${RELEASE_ARCHES}" fi if [[ "${DEBUG_LLDB}" == "1" ]]; then run_step "package app" env CODEXBAR_ALLOW_LLDB=1 ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" debug else if [[ -n "${SIGNING_MODE}" ]]; then run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" else run_step "package app" env ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" fi fi # 4) Launch the packaged app. log "==> launch app" if ! open "${APP_BUNDLE}"; then log "WARN: launch app returned non-zero; falling back to direct binary launch." "${APP_BUNDLE}/Contents/MacOS/CodexBar" >/dev/null 2>&1 & disown fi # 5) Verify the app stays up for at least a moment (launch can be >1s on some systems). for _ in {1..10}; do if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then log "OK: CodexBar is running." exit 0 fi sleep 0.4 done fail "App exited immediately. Check crash logs in Console.app (User Reports)." ================================================ FILE: Scripts/docs-list.mjs ================================================ #!/usr/bin/env node import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs'); const EXCLUDED_DIRS = new Set(['archive', 'research']); function walkMarkdownFiles(dir, base = dir) { const entries = readdirSync(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if (EXCLUDED_DIRS.has(entry.name)) continue; files.push(...walkMarkdownFiles(fullPath, base)); } else if (entry.isFile() && entry.name.endsWith('.md')) { files.push(relative(base, fullPath)); } } return files.sort((a, b) => a.localeCompare(b)); } function extractMetadata(fullPath) { const content = readFileSync(fullPath, 'utf8'); if (!content.startsWith('---')) { return { summary: null, readWhen: [], error: 'missing front matter' }; } const endIndex = content.indexOf('\n---', 3); if (endIndex === -1) { return { summary: null, readWhen: [], error: 'unterminated front matter' }; } const frontMatter = content.slice(3, endIndex).trim(); const lines = frontMatter.split('\n'); let summaryLine = null; const readWhen = []; let collectingReadWhen = false; for (const rawLine of lines) { const line = rawLine.trim(); if (line.startsWith('summary:')) { summaryLine = line; collectingReadWhen = false; continue; } if (line.startsWith('read_when:')) { collectingReadWhen = true; const inline = line.slice('read_when:'.length).trim(); if (inline.startsWith('[') && inline.endsWith(']')) { try { const parsed = JSON.parse(inline.replace(/'/g, '"')); if (Array.isArray(parsed)) { parsed .map((v) => String(v).trim()) .filter(Boolean) .forEach((v) => readWhen.push(v)); } } catch { /* ignore malformed inline */ } } continue; } if (collectingReadWhen) { if (line.startsWith('- ')) { const hint = line.slice(2).trim(); if (hint) readWhen.push(hint); } else if (line === '') { // allow blank spacer lines inside list } else { collectingReadWhen = false; } } } if (!summaryLine) { return { summary: null, readWhen, error: 'summary key missing' }; } const summaryValue = summaryLine.slice('summary:'.length).trim(); const normalized = summaryValue.replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim(); if (!normalized) { return { summary: null, readWhen, error: 'summary is empty' }; } return { summary: normalized, readWhen }; } console.log('Listing all markdown files in docs folder:'); const markdownFiles = walkMarkdownFiles(DOCS_DIR); for (const relativePath of markdownFiles) { const fullPath = join(DOCS_DIR, relativePath); const { summary, readWhen, error } = extractMetadata(fullPath); if (summary) { console.log(`${relativePath} - ${summary}`); if (readWhen.length > 0) { console.log(` Read when: ${readWhen.join('; ')}`); } } else { const reason = error ? ` - [${error}]` : ''; console.log(`${relativePath}${reason}`); } } console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.'); ================================================ FILE: Scripts/install_lint_tools.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TOOLS_DIR="${ROOT_DIR}/.build/lint-tools" BIN_DIR="${TOOLS_DIR}/bin" SWIFTFORMAT_VERSION="0.59.1" SWIFTLINT_VERSION="0.63.2" SWIFTFORMAT_SHA256_DARWIN="8b6289b608a44e73cd3851c3589dbd7c553f32cc805aa54b3a496ce2b90febe7" SWIFTLINT_SHA256_DARWIN="c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } sha256_value() { local path="$1" if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$path" | awk '{print $1}' return 0 fi if command -v sha256sum >/dev/null 2>&1; then sha256sum "$path" | awk '{print $1}' return 0 fi fail "Missing shasum/sha256sum." } download_file() { local url="$1" local out="$2" curl -fL --retry 3 --retry-connrefused --retry-delay 2 -o "$out" "$url" } install_zip_binary() { local label="$1" local url="$2" local expected_sha="$3" local binary_name="$4" local tmp_zip tmp_zip="$(mktemp -t "${label}.XXXX")" local tmp_dir tmp_dir="$(mktemp -d -t "${label}.XXXX")" log "==> Downloading ${label}" download_file "$url" "$tmp_zip" local actual_sha actual_sha="$(sha256_value "$tmp_zip")" if [[ -n "$expected_sha" && "$actual_sha" != "$expected_sha" ]]; then rm -f "$tmp_zip" rm -rf "$tmp_dir" fail "${label} SHA256 mismatch (expected ${expected_sha}, got ${actual_sha})" fi unzip -q "$tmp_zip" -d "$tmp_dir" local extracted_path="" if [[ -f "${tmp_dir}/${binary_name}" ]]; then extracted_path="${tmp_dir}/${binary_name}" else extracted_path="$(find "$tmp_dir" -type f -name "$binary_name" | head -n 1 || true)" fi if [[ -z "$extracted_path" || ! -f "$extracted_path" ]]; then rm -f "$tmp_zip" rm -rf "$tmp_dir" fail "${label} binary '${binary_name}' not found in archive" fi install -m 0755 "$extracted_path" "${BIN_DIR}/${binary_name}" rm -f "$tmp_zip" rm -rf "$tmp_dir" } mkdir -p "$BIN_DIR" if [[ -x "${BIN_DIR}/swiftformat" && -x "${BIN_DIR}/swiftlint" ]]; then if [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]] \ && [[ "$("${BIN_DIR}/swiftlint" version 2>/dev/null || true)" == "${SWIFTLINT_VERSION}" ]] then log "==> Lint tools already installed (${SWIFTFORMAT_VERSION}, ${SWIFTLINT_VERSION})" exit 0 fi fi OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Darwin) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat" install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint" ;; Linux) case "$ARCH" in x86_64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip" ;; aarch64|arm64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip" ;; *) fail "Unsupported Linux arch: ${ARCH}" ;; esac # SHA256 is intentionally only enforced for the macOS CI path. # If we later run lint on Linux CI, add pinned SHAs here as well. log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway." install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "" "swiftformat" install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "" "swiftlint" ;; *) fail "Unsupported OS: ${OS}" ;; esac log "==> Installed lint tools to ${BIN_DIR}" "${BIN_DIR}/swiftformat" --version "${BIN_DIR}/swiftlint" version ================================================ FILE: Scripts/launch.sh ================================================ #!/bin/bash set -euo pipefail # Simple script to launch CodexBar (kills existing instance first) # Usage: ./Scripts/launch.sh SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" APP_PATH="$PROJECT_ROOT/CodexBar.app" echo "==> Killing existing CodexBar instances" pkill -x CodexBar || pkill -f CodexBar.app || true sleep 0.5 if [[ ! -d "$APP_PATH" ]]; then echo "ERROR: CodexBar.app not found at $APP_PATH" echo "Run ./Scripts/package_app.sh first to build the app" exit 1 fi echo "==> Launching CodexBar from $APP_PATH" open -n "$APP_PATH" # Wait a moment and check if it's running sleep 1 if pgrep -x CodexBar > /dev/null; then echo "OK: CodexBar is running." else echo "ERROR: App exited immediately. Check crash logs in Console.app (User Reports)." exit 1 fi ================================================ FILE: Scripts/lint.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin" ensure_tools() { # Always delegate to the installer so pinned versions are enforced. # The installer is idempotent and exits early when the expected versions are already present. "${ROOT_DIR}/Scripts/install_lint_tools.sh" } cmd="${1:-lint}" case "$cmd" in lint) ensure_tools "${BIN_DIR}/swiftformat" Sources Tests --lint "${BIN_DIR}/swiftlint" --strict ;; format) ensure_tools "${BIN_DIR}/swiftformat" Sources Tests ;; *) printf 'Usage: %s [lint|format]\n' "$(basename "$0")" >&2 exit 2 ;; esac ================================================ FILE: Scripts/make_appcast.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) ZIP=${1:? "Usage: $0 CodexBar-.zip"} FEED_URL=${2:-"https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml"} PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-} SPARKLE_CHANNEL=${SPARKLE_CHANNEL:-} if [[ -z "$PRIVATE_KEY_FILE" ]]; then echo "Set SPARKLE_PRIVATE_KEY_FILE to your ed25519 private key (Sparkle)." >&2 exit 1 fi if [[ ! -f "$ZIP" ]]; then echo "Zip not found: $ZIP" >&2 exit 1 fi ZIP_DIR=$(cd "$(dirname "$ZIP")" && pwd) ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then if [[ "$ZIP_NAME" =~ ^CodexBar-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 exit 1 fi fi NOTES_HTML="${ZIP_DIR}/${ZIP_BASE}.html" KEEP_NOTES=${KEEP_SPARKLE_NOTES:-0} if [[ -x "$ROOT/Scripts/changelog-to-html.sh" ]]; then "$ROOT/Scripts/changelog-to-html.sh" "$VERSION" >"$NOTES_HTML" else echo "Missing Scripts/changelog-to-html.sh; cannot generate HTML release notes." >&2 exit 1 fi cleanup() { if [[ -n "${WORK_DIR:-}" ]]; then rm -rf "$WORK_DIR" fi if [[ "$KEEP_NOTES" != "1" ]]; then rm -f "$NOTES_HTML" fi } trap cleanup EXIT DOWNLOAD_URL_PREFIX=${SPARKLE_DOWNLOAD_URL_PREFIX:-"https://github.com/steipete/CodexBar/releases/download/v${VERSION}/"} # Sparkle provides generate_appcast; ensure it's on PATH (via SwiftPM build of Sparkle's bin) or Xcode dmg if ! command -v generate_appcast >/dev/null; then echo "generate_appcast not found in PATH. Install Sparkle tools (see Sparkle docs)." >&2 exit 1 fi WORK_DIR=$(mktemp -d /tmp/codexbar-appcast.XXXXXX) cp "$ROOT/appcast.xml" "$WORK_DIR/appcast.xml" cp "$ZIP" "$WORK_DIR/$ZIP_NAME" cp "$NOTES_HTML" "$WORK_DIR/$ZIP_BASE.html" pushd "$WORK_DIR" >/dev/null generate_appcast \ --ed-key-file "$PRIVATE_KEY_FILE" \ --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ --embed-release-notes \ --link "$FEED_URL" \ "$WORK_DIR" popd >/dev/null if [[ -n "$SPARKLE_CHANNEL" ]]; then python3 - "$WORK_DIR/appcast.xml" "$VERSION" "$SPARKLE_CHANNEL" <<'PY' import re import sys path, version, channel = sys.argv[1], sys.argv[2], sys.argv[3] with open(path, "r", encoding="utf-8") as handle: lines = handle.read().splitlines() target = f"{version}" try: index = next(i for i, line in enumerate(lines) if target in line) except StopIteration as exc: raise SystemExit(f"Could not find {target} in {path}") from exc for j in range(index, -1, -1): if " for version {version} in {path}") with open(path, "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + "\n") PY echo "Tagged ${VERSION} with sparkle:channel=\"${SPARKLE_CHANNEL}\"" fi cp "$WORK_DIR/appcast.xml" "$ROOT/appcast.xml" echo "Appcast generated (appcast.xml). Upload alongside $ZIP at $FEED_URL" ================================================ FILE: Scripts/package_app.sh ================================================ #!/usr/bin/env bash set -euo pipefail CONF=${1:-release} ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0} SIGNING_MODE=${CODEXBAR_SIGNING:-} ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" # Load version info source "$ROOT/version.env" # Clean build only when explicitly requested (slower). if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then if [[ -d "$ROOT/.build" ]]; then if command -v trash >/dev/null 2>&1; then if ! trash "$ROOT/.build"; then echo "WARN: trash .build failed; continuing with swift package clean." >&2 fi else rm -rf "$ROOT/.build" || echo "WARN: rm -rf .build failed; continuing with swift package clean." >&2 fi fi swift package clean >/dev/null 2>&1 || true fi # Build for host architecture by default; allow overriding via ARCHES (e.g., "arm64 x86_64" for universal). ARCH_LIST=( ${ARCHES:-} ) if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then HOST_ARCH=$(uname -m) case "$HOST_ARCH" in arm64) ARCH_LIST=(arm64) ;; x86_64) ARCH_LIST=(x86_64) ;; *) ARCH_LIST=("$HOST_ARCH") ;; esac fi patch_keyboard_shortcuts() { local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$util_path" ]]; then return 0 fi if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then return 0 fi chmod +w "$util_path" || true python3 - "$util_path" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) text = path.read_text() if ".keyboardShortcutsSafeBundle" in text: sys.exit(0) text = text.replace( 'NSLocalizedString(self, bundle: .module, comment: self)', 'NSLocalizedString(self, bundle: .keyboardShortcutsSafeBundle, comment: self)', ) inject = """ private extension Bundle { /// Safe lookup that avoids the fatal trap in the autogenerated `Bundle.module` /// when the resource bundle is not placed at the bundle root. static let keyboardShortcutsSafeBundle: Bundle = { #if os(macOS) if let url = Bundle.main.url(forResource: "KeyboardShortcuts_KeyboardShortcuts", withExtension: "bundle"), let bundle = Bundle(url: url) { return bundle } let rootURL = Bundle.main.bundleURL.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle") if let bundle = Bundle(url: rootURL) { return bundle } #endif let devURL = URL(fileURLWithPath: #file) .deletingLastPathComponent() // Utilities.swift .deletingLastPathComponent() // KeyboardShortcuts .deletingLastPathComponent() // Sources .appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle") if let bundle = Bundle(url: devURL) { return bundle } return Bundle.main }() } """ marker = "}\n\n\nextension Data {" if marker not in text: raise SystemExit("Marker not found in Utilities.swift; patch failed.") text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {") path.write_text(text) PY } KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then swift build -c "$CONF" --arch "${ARCH_LIST[0]}" fi patch_keyboard_shortcuts for ARCH in "${ARCH_LIST[@]}"; do swift build -c "$CONF" --arch "$ARCH" done APP="$ROOT/CodexBar.app" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns" # Convert new .icon bundle to .icns if present (macOS 14+/IconStudio export) ICON_SOURCE="$ROOT/Icon.icon" ICON_TARGET="$ROOT/Icon.icns" if [[ -f "$ICON_SOURCE" ]]; then iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE" fi BUNDLE_ID="com.steipete.codexbar" FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml" AUTO_CHECKS=true LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') if [[ "$LOWER_CONF" == "debug" ]]; then BUNDLE_ID="com.steipete.codexbar.debug" FEED_URL="" AUTO_CHECKS=false fi if [[ "$SIGNING_MODE" == "adhoc" ]]; then FEED_URL="" AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" APP_GROUP_ID="group.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then APP_GROUP_ID="group.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements" mkdir -p "$ENTITLEMENTS_DIR" if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2 exit 1 fi cat > "$APP_ENTITLEMENTS" < com.apple.security.application-groups ${APP_GROUP_ID} $(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " com.apple.security.get-task-allow"; fi) PLIST cat > "$WIDGET_ENTITLEMENTS" < com.apple.security.app-sandbox com.apple.security.application-groups ${APP_GROUP_ID} PLIST BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") cat > "$APP/Contents/Info.plist" < CFBundleNameCodexBar CFBundleDisplayNameCodexBar CFBundleIdentifier${BUNDLE_ID} CFBundleExecutableCodexBar CFBundlePackageTypeAPPL CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 LSUIElement CFBundleIconFileIcon NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} SUPublicEDKeyAGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI= SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} PLIST build_product_path() { local name="$1" local arch="$2" case "$arch" in arm64|x86_64) echo ".build/${arch}-apple-macosx/$CONF/$name" ;; *) echo ".build/$CONF/$name" ;; esac } # Resolve path to built binary; some SwiftPM versions use .build/$CONF/ when building for host only. resolve_binary_path() { local name="$1" local arch="$2" local candidate candidate=$(build_product_path "$name" "$arch") if [[ -f "$candidate" ]]; then echo "$candidate" return fi if [[ "$arch" == "arm64" || "$arch" == "x86_64" ]] && [[ -f ".build/$CONF/$name" ]]; then echo ".build/$CONF/$name" fi } verify_binary_arches() { local binary="$1"; shift local expected=("$@") local actual actual=$(lipo -archs "$binary") local actual_count expected_count actual_count=$(wc -w <<<"$actual" | tr -d ' ') expected_count=${#expected[@]} if [[ "$actual_count" -ne "$expected_count" ]]; then echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2 exit 1 fi for arch in "${expected[@]}"; do if [[ "$actual" != *"$arch"* ]]; then echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2 exit 1 fi done } install_binary() { local name="$1" local dest="$2" local binaries=() for arch in "${ARCH_LIST[@]}"; do local src src=$(resolve_binary_path "$name" "$arch") if [[ -z "$src" || ! -f "$src" ]]; then echo "ERROR: Missing ${name} build for ${arch} at $(build_product_path "$name" "$arch")" >&2 exit 1 fi binaries+=("$src") done if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then lipo -create "${binaries[@]}" -output "$dest" else cp "${binaries[0]}" "$dest" fi chmod +x "$dest" verify_binary_arches "$dest" "${ARCH_LIST[@]}" } install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. if [[ -n "$(resolve_binary_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" fi # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. if [[ -n "$(resolve_binary_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" fi if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then WIDGET_APP="$APP/Contents/PlugIns/CodexBarWidget.appex" mkdir -p "$WIDGET_APP/Contents/MacOS" "$WIDGET_APP/Contents/Resources" cat > "$WIDGET_APP/Contents/Info.plist" < CFBundleNameCodexBarWidget CFBundleDisplayNameCodexBar CFBundleIdentifier${WIDGET_BUNDLE_ID} CFBundleExecutableCodexBarWidget CFBundlePackageTypeXPC! CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 NSExtension NSExtensionPointIdentifiercom.apple.widgetkit-extension NSExtensionPrincipalClassCodexBarWidget.CodexBarWidgetBundle PLIST install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" fi # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/" chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" # Re-sign Sparkle and all nested components with Developer ID + timestamp SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" if [[ "$SIGNING_MODE" == "adhoc" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") elif [[ "$ALLOW_LLDB" == "1" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") else CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}" CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID") fi function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; } # Sign innermost binaries first, then the framework root to seal resources resign "$SPARKLE" resign "$SPARKLE/Versions/B/Sparkle" resign "$SPARKLE/Versions/B/Autoupdate" resign "$SPARKLE/Versions/B/Updater.app" resign "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc" resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" resign "$SPARKLE/Versions/B" resign "$SPARKLE" fi if [[ -f "$ICON_TARGET" ]]; then cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns" fi # Bundle app resources (provider icons, etc.). APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources" if [[ -d "$APP_RESOURCES_DIR" ]]; then cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/" fi if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2 exit 1 fi # SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary. CODEXBAR_BINARY="$(resolve_binary_path "CodexBar" "${ARCH_LIST[0]}")" PREFERRED_BUILD_DIR="$(dirname "${CODEXBAR_BINARY:-$(build_product_path "CodexBar" "${ARCH_LIST[0]}")}")" shopt -s nullglob SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle) shopt -u nullglob if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then for bundle in "${SWIFTPM_BUNDLES[@]}"; do bundle_name="$(basename "$bundle")" cp -R "$bundle" "$APP/Contents/Resources/" done fi if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" ]]; then echo "ERROR: Missing KeyboardShortcuts SwiftPM resource bundle (Settings → Keyboard shortcut will crash)." >&2 echo "Expected: ${PREFERRED_BUILD_DIR}/KeyboardShortcuts_KeyboardShortcuts.bundle" >&2 exit 1 fi # Ensure contents are writable before stripping attributes and signing. chmod -R u+w "$APP" # Strip extended attributes to prevent AppleDouble (._*) files that break code sealing xattr -cr "$APP" find "$APP" -name '._*' -delete # Sign helper binaries if present if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarCLI" fi if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" fi # Sign widget extension if present if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP/Contents/PlugIns/CodexBarWidget.appex" fi # Finally sign the app bundle itself codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" echo "Created $APP" ================================================ FILE: Scripts/prepare_upstream_pr.sh ================================================ #!/bin/bash # Prepare a clean branch for upstream PR submission # Usage: ./Scripts/prepare_upstream_pr.sh set -e FEATURE_NAME=$1 # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' if [ -z "$FEATURE_NAME" ]; then echo -e "${RED}Error: Feature name required${NC}" echo "Usage: ./Scripts/prepare_upstream_pr.sh " echo "" echo "Examples:" echo " ./Scripts/prepare_upstream_pr.sh fix-cursor-bonus" echo " ./Scripts/prepare_upstream_pr.sh improve-cookie-handling" exit 1 fi BRANCH_NAME="upstream-pr/$FEATURE_NAME" echo -e "${BLUE}==> Fetching latest upstream...${NC}" git fetch upstream echo -e "${BLUE}==> Creating upstream PR branch from upstream/main...${NC}" git checkout upstream/main git checkout -b "$BRANCH_NAME" echo "" echo -e "${GREEN}==> Branch created: $BRANCH_NAME${NC}" echo "" echo -e "${YELLOW}⚠️ IMPORTANT: This branch is for UPSTREAM submission${NC}" echo "" echo -e "${BLUE}Guidelines for upstream PRs:${NC}" echo "" echo "✅ DO include:" echo " - Bug fixes that affect all users" echo " - Performance improvements" echo " - Provider enhancements (generic)" echo " - Documentation improvements" echo " - Test coverage" echo "" echo "❌ DO NOT include:" echo " - Fork branding (About.swift, PreferencesAboutPane.swift)" echo " - Fork-specific features (multi-account, etc.)" echo " - References to topoffunnel.com" echo " - Experimental features" echo "" echo -e "${BLUE}Next steps:${NC}" echo "" echo "1. Cherry-pick your commits (clean, no fork branding):" echo " ${GREEN}git cherry-pick ${NC}" echo "" echo "2. Or manually apply changes:" echo " ${GREEN}# Edit files${NC}" echo " ${GREEN}git add ${NC}" echo " ${GREEN}git commit -m 'fix: description'${NC}" echo "" echo "3. Ensure tests pass:" echo " ${GREEN}swift test${NC}" echo "" echo "4. Review changes:" echo " ${GREEN}git diff upstream/main${NC}" echo "" echo "5. Push to your fork:" echo " ${GREEN}git push origin $BRANCH_NAME${NC}" echo "" echo "6. Create PR on GitHub:" echo " ${GREEN}https://github.com/steipete/CodexBar/compare/main...topoffunnel:$BRANCH_NAME${NC}" echo "" echo -e "${YELLOW}Remember: Keep PRs small and focused for better merge chances!${NC}" ================================================ FILE: Scripts/release.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" source "$ROOT/version.env" source "$HOME/Projects/agent-scripts/release/sparkle_lib.sh" APPCAST="$ROOT/appcast.xml" APP_NAME="CodexBar" ARTIFACT_PREFIX="CodexBar-" BUNDLE_ID="com.steipete.codexbar" TAG="v${MARKETING_VERSION}" err() { echo "ERROR: $*" >&2; exit 1; } require_clean_worktree ensure_changelog_finalized "$MARKETING_VERSION" ensure_appcast_monotonic "$APPCAST" "$MARKETING_VERSION" "$BUILD_NUMBER" swiftformat Sources Tests >/dev/null swiftlint --strict swift test # Note: run this script in the foreground; do not background it so it waits to completion. "$ROOT/Scripts/sign-and-notarize.sh" KEY_FILE=$(clean_key "$SPARKLE_PRIVATE_KEY_FILE") trap 'rm -f "$KEY_FILE"' EXIT probe_sparkle_key "$KEY_FILE" clear_sparkle_caches "$BUNDLE_ID" NOTES_FILE=$(mktemp /tmp/codexbar-notes.XXXXXX.md) extract_notes_from_changelog "$MARKETING_VERSION" "$NOTES_FILE" trap 'rm -f "$KEY_FILE" "$NOTES_FILE"' EXIT git tag -s -f -m "${APP_NAME} ${MARKETING_VERSION}" "$TAG" git push -f origin "$TAG" gh release create "$TAG" ${APP_NAME}-${MARKETING_VERSION}.zip ${APP_NAME}-${MARKETING_VERSION}.dSYM.zip \ --title "${APP_NAME} ${MARKETING_VERSION}" \ --notes-file "$NOTES_FILE" SPARKLE_PRIVATE_KEY_FILE="$KEY_FILE" \ "$ROOT/Scripts/make_appcast.sh" \ "${APP_NAME}-${MARKETING_VERSION}.zip" \ "https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml" verify_appcast_entry "$APPCAST" "$MARKETING_VERSION" "$KEY_FILE" git add "$APPCAST" git commit -m "docs: update appcast for ${MARKETING_VERSION}" git push origin main if [[ "${RUN_SPARKLE_UPDATE_TEST:-0}" == "1" ]]; then PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p') [[ -z "$PREV_TAG" ]] && err "RUN_SPARKLE_UPDATE_TEST=1 set but no previous tag found" "$ROOT/Scripts/test_live_update.sh" "$PREV_TAG" "v${MARKETING_VERSION}" fi check_assets "$TAG" "$ARTIFACT_PREFIX" git push origin --tags echo "Release ${MARKETING_VERSION} complete." ================================================ FILE: Scripts/review_upstream.sh ================================================ #!/bin/bash # Create a review branch for upstream changes # Usage: ./Scripts/review_upstream.sh [upstream|quotio] set -e UPSTREAM=${1:-upstream} DATE=$(date +%Y%m%d) BRANCH_NAME="upstream-sync/${UPSTREAM}-${DATE}" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' if [ "$UPSTREAM" != "upstream" ] && [ "$UPSTREAM" != "quotio" ]; then echo -e "${RED}Error: Must specify 'upstream' or 'quotio'${NC}" echo "Usage: ./Scripts/review_upstream.sh [upstream|quotio]" exit 1 fi echo -e "${BLUE}==> Creating review branch for $UPSTREAM...${NC}" git checkout main git checkout -b "$BRANCH_NAME" echo -e "${BLUE}==> Fetching latest from $UPSTREAM...${NC}" git fetch "$UPSTREAM" echo "" echo -e "${GREEN}==> Commits to review:${NC}" git log --oneline --graph main.."$UPSTREAM"/main | head -30 echo "" echo -e "${GREEN}==> File changes summary:${NC}" git diff --stat main.."$UPSTREAM"/main echo "" echo -e "${YELLOW}==> Review branch created: $BRANCH_NAME${NC}" echo "" echo -e "${BLUE}Next steps:${NC}" echo "" echo "1. Review commits in detail:" echo " ${GREEN}git log -p main..$UPSTREAM/main${NC}" echo "" echo "2. View specific files:" echo " ${GREEN}git show $UPSTREAM/main:path/to/file${NC}" echo "" echo "3. Cherry-pick specific commits:" echo " ${GREEN}git cherry-pick ${NC}" echo "" echo "4. Or merge all changes:" echo " ${GREEN}git merge $UPSTREAM/main${NC}" echo "" echo "5. Test thoroughly:" echo " ${GREEN}./Scripts/compile_and_run.sh${NC}" echo "" echo "6. If satisfied, merge to main:" echo " ${GREEN}git checkout main && git merge $BRANCH_NAME${NC}" echo "" echo "7. Or discard review branch:" echo " ${GREEN}git checkout main && git branch -D $BRANCH_NAME${NC}" echo "" # Create a review log file LOG_FILE="upstream-review-${UPSTREAM}-${DATE}.txt" echo "=== Upstream Review: $UPSTREAM @ $DATE ===" > "$LOG_FILE" echo "" >> "$LOG_FILE" echo "Commits:" >> "$LOG_FILE" git log --oneline main.."$UPSTREAM"/main >> "$LOG_FILE" echo "" >> "$LOG_FILE" echo "File changes:" >> "$LOG_FILE" git diff --stat main.."$UPSTREAM"/main >> "$LOG_FILE" echo -e "${GREEN}Review log saved to: $LOG_FILE${NC}" ================================================ FILE: Scripts/setup_dev_signing.sh ================================================ #!/usr/bin/env bash # Setup stable development code signing to reduce keychain prompts set -euo pipefail echo "🔐 Setting up stable development code signing..." echo "" echo "This will create a self-signed certificate that stays consistent across rebuilds," echo "reducing keychain permission prompts." echo "" # Check if we already have a CodexBar development certificate CERT_NAME="CodexBar Development" if security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then echo "✅ Certificate '$CERT_NAME' already exists!" echo "" echo "To use it, add this to your shell profile (~/.zshrc or ~/.bashrc):" echo "" echo " export APP_IDENTITY='$CERT_NAME'" echo "" echo "Then restart your terminal and rebuild with ./Scripts/compile_and_run.sh" exit 0 fi echo "Creating self-signed certificate '$CERT_NAME'..." echo "" # Create a temporary config file for the certificate TEMP_CONFIG=$(mktemp) trap "rm -f $TEMP_CONFIG" EXIT cat > "$TEMP_CONFIG" </dev/null # Convert to PKCS12 format openssl pkcs12 -export -out /tmp/codexbar-dev.p12 \ -inkey /tmp/codexbar-dev.key -in /tmp/codexbar-dev.crt \ -passout pass: 2>/dev/null # Import into keychain security import /tmp/codexbar-dev.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security # Clean up temporary files rm -f /tmp/codexbar-dev.{key,crt,p12} echo "" echo "✅ Certificate created successfully!" echo "" echo "⚠️ IMPORTANT: You need to trust this certificate for code signing:" echo "" echo "1. Open Keychain Access.app" echo "2. Find '$CERT_NAME' in the 'login' keychain" echo "3. Double-click it" echo "4. Expand 'Trust' section" echo "5. Set 'Code Signing' to 'Always Trust'" echo "6. Close the window (enter your password when prompted)" echo "" echo "Then add this to your shell profile (~/.zshrc or ~/.bashrc):" echo "" echo " export APP_IDENTITY='$CERT_NAME'" echo "" echo "Restart your terminal and rebuild with ./Scripts/compile_and_run.sh" ================================================ FILE: Scripts/sign-and-notarize.sh ================================================ #!/usr/bin/env bash set -euo pipefail APP_NAME="CodexBar" APP_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" APP_BUNDLE="CodexBar.app" ROOT=$(cd "$(dirname "$0")/.." && pwd) source "$ROOT/version.env" ZIP_NAME="${APP_NAME}-${MARKETING_VERSION}.zip" DSYM_ZIP="${APP_NAME}-${MARKETING_VERSION}.dSYM.zip" if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 exit 1 fi if [[ -z "${SPARKLE_PRIVATE_KEY_FILE:-}" ]]; then echo "SPARKLE_PRIVATE_KEY_FILE is required for release signing/verification." >&2 exit 1 fi if [[ ! -f "$SPARKLE_PRIVATE_KEY_FILE" ]]; then echo "Sparkle key file not found: $SPARKLE_PRIVATE_KEY_FILE" >&2 exit 1 fi key_lines=$(grep -v '^[[:space:]]*#' "$SPARKLE_PRIVATE_KEY_FILE" | sed '/^[[:space:]]*$/d') if [[ $(printf "%s\n" "$key_lines" | wc -l) -ne 1 ]]; then echo "Sparkle key file must contain exactly one base64 line (no comments/blank lines)." >&2 exit 1 fi echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 trap 'rm -f /tmp/codexbar-api-key.p8 /tmp/${APP_NAME}Notarize.zip' EXIT # Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} ARCH_LIST=( ${ARCHES_VALUE} ) for ARCH in "${ARCH_LIST[@]}"; do swift build -c release --arch "$ARCH" done ARCHES="${ARCHES_VALUE}" ./Scripts/package_app.sh release ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements" echo "Signing with $APP_IDENTITY" if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarCLI" ]]; then codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ "$APP_BUNDLE/Contents/Helpers/CodexBarCLI" fi if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ "$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog" fi if [[ -d "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex" ]]; then codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ --entitlements "$WIDGET_ENTITLEMENTS" \ "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex" fi codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP_BUNDLE" DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip" echo "Submitting for notarization" xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \ --key /tmp/codexbar-api-key.p8 \ --key-id "$APP_STORE_CONNECT_KEY_ID" \ --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait echo "Stapling ticket" xcrun stapler staple "$APP_BUNDLE" # Strip any extended attributes that would create AppleDouble files when zipping xattr -cr "$APP_BUNDLE" find "$APP_BUNDLE" -name '._*' -delete "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME" spctl -a -t exec -vv "$APP_BUNDLE" stapler validate "$APP_BUNDLE" echo "Packaging dSYM" FIRST_ARCH="${ARCH_LIST[0]}" PREFERRED_ARCH_DIR=".build/${FIRST_ARCH}-apple-macosx/release" DSYM_PATH="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM" if [[ ! -d "$DSYM_PATH" ]]; then echo "Missing dSYM at $DSYM_PATH" >&2 exit 1 fi if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then MERGED_DSYM="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM-universal" rm -rf "$MERGED_DSYM" cp -R "$DSYM_PATH" "$MERGED_DSYM" DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}" BINARIES=() for ARCH in "${ARCH_LIST[@]}"; do ARCH_DSYM=".build/${ARCH}-apple-macosx/release/${APP_NAME}.dSYM/Contents/Resources/DWARF/${APP_NAME}" if [[ ! -f "$ARCH_DSYM" ]]; then echo "Missing dSYM for ${ARCH} at $ARCH_DSYM" >&2 exit 1 fi BINARIES+=("$ARCH_DSYM") done lipo -create "${BINARIES[@]}" -output "$DWARF_PATH" DSYM_PATH="$MERGED_DSYM" fi "$DITTO_BIN" --norsrc -c -k --keepParent "$DSYM_PATH" "$DSYM_ZIP" echo "Done: $ZIP_NAME" ================================================ FILE: Scripts/test_live_update.sh ================================================ #!/usr/bin/env bash set -euo pipefail PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"} CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"} ROOT=$(cd "$(dirname "$0")/.." && pwd) PREV_VER=${PREV_TAG#v} APP_NAME="CodexBar" ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-${PREV_VER}.zip" TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX) trap 'rm -rf "$TMP_DIR"' EXIT echo "Downloading previous release $PREV_TAG from $ZIP_URL" curl -L -o "$TMP_DIR/prev.zip" "$ZIP_URL" echo "Installing previous release to /Applications/${APP_NAME}.app" rm -rf /Applications/${APP_NAME}.app ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR" ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app echo "Launching previous build…" open -n /Applications/${APP_NAME}.app sleep 4 cat <<'MSG' Manual step: trigger "Check for Updates…" in the app and install the update. Expect to land on the newly released version. When done, confirm below. MSG read -rp "Did the update succeed from ${PREV_TAG} to ${CUR_TAG}? (y/N) " answer if [[ ! "$answer" =~ ^[Yy]$ ]]; then echo "Live update test NOT confirmed; failing per RUN_SPARKLE_UPDATE_TEST." >&2 exit 1 fi echo "Live update test confirmed." ================================================ FILE: Scripts/validate_changelog.sh ================================================ #!/usr/bin/env bash set -euo pipefail VERSION=${1:?"usage: $0 "} ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" first_line=$(grep -m1 '^## ' CHANGELOG.md | sed 's/^## //') if [[ "$first_line" != ${VERSION}* ]]; then echo "ERROR: Top CHANGELOG section is '$first_line' but expected '${VERSION} — …'" >&2 exit 1 fi grep -q "^## ${VERSION} " CHANGELOG.md || { echo "ERROR: No section for version ${VERSION} in CHANGELOG.md" >&2 exit 1 } grep -q '^## [0-9]\+\.[0-9]\+\.[0-9].*Unreleased' CHANGELOG.md && { echo "ERROR: Top section still labeled Unreleased; finalize changelog first." >&2 exit 1 } echo "Changelog OK for ${VERSION}" ================================================ FILE: Scripts/verify_appcast.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Verifies that the appcast entry for the given version has a valid ed25519 signature # and that the enclosure length matches the downloaded archive. # # Usage: SPARKLE_PRIVATE_KEY_FILE=/path/to/key ./Scripts/verify_appcast.sh [version] ROOT=$(cd "$(dirname "$0")/.." && pwd) VERSION=${1:-$(source "$ROOT/version.env" && echo "$MARKETING_VERSION")} APPCAST="${ROOT}/appcast.xml" if [[ -z "${SPARKLE_PRIVATE_KEY_FILE:-}" ]]; then echo "SPARKLE_PRIVATE_KEY_FILE is required" >&2 exit 1 fi if [[ ! -f "$SPARKLE_PRIVATE_KEY_FILE" ]]; then echo "Sparkle key file not found: $SPARKLE_PRIVATE_KEY_FILE" >&2 exit 1 fi if [[ ! -f "$APPCAST" ]]; then echo "appcast.xml not found at $APPCAST" >&2 exit 1 fi # Clean the key file: strip comments/blank lines and require exactly one line of base64. function cleaned_key_path() { local tmp key_lines key_lines=$(grep -v '^[[:space:]]*#' "$SPARKLE_PRIVATE_KEY_FILE" | sed '/^[[:space:]]*$/d') if [[ $(printf "%s\n" "$key_lines" | wc -l) -ne 1 ]]; then echo "Sparkle key file must contain exactly one base64 line (no comments/blank lines)." >&2 exit 1 fi tmp=$(mktemp) printf "%s" "$key_lines" > "$tmp" echo "$tmp" } KEY_FILE=$(cleaned_key_path) trap 'rm -f "$KEY_FILE" "$TMP_ZIP"' EXIT TMP_ZIP=$(mktemp /tmp/codexbar-enclosure.XXXX.zip) python3 - "$APPCAST" "$VERSION" >"$TMP_ZIP.meta" <<'PY' import sys, xml.etree.ElementTree as ET appcast = sys.argv[1] version = sys.argv[2] tree = ET.parse(appcast) root = tree.getroot() ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"} entry = None for item in root.findall("./channel/item"): sv = item.findtext("sparkle:shortVersionString", default="", namespaces=ns) if sv == version: entry = item break if entry is None: sys.exit("No appcast entry found for version {}".format(version)) enclosure = entry.find("enclosure") url = enclosure.get("url") sig = enclosure.get("{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature") length = enclosure.get("length") if not all([url, sig, length]): sys.exit("Missing url/signature/length in appcast for version {}".format(version)) print(url) print(sig) print(length) PY readarray -t META <"$TMP_ZIP.meta" URL="${META[0]}" SIG="${META[1]}" LEN_EXPECTED="${META[2]}" echo "Downloading enclosure: $URL" curl -L -o "$TMP_ZIP" "$URL" LEN_ACTUAL=$(stat -f%z "$TMP_ZIP") if [[ "$LEN_ACTUAL" != "$LEN_EXPECTED" ]]; then echo "Length mismatch: expected $LEN_EXPECTED, got $LEN_ACTUAL" >&2 exit 1 fi echo "Verifying Sparkle signature…" sign_update --verify "$TMP_ZIP" "$SIG" --ed-key-file "$KEY_FILE" echo "Appcast entry for $VERSION verified (signature and length match)." ================================================ FILE: Sources/CodexBar/About.swift ================================================ import AppKit @MainActor func showAbout() { NSApp.activate(ignoringOtherApps: true) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "" let versionString = build.isEmpty ? version : "\(version) (\(build))" let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String let separator = NSAttributedString(string: " · ", attributes: [ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), ]) func makeLink(_ title: String, urlString: String) -> NSAttributedString { NSAttributedString(string: title, attributes: [ .link: URL(string: urlString) as Any, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), ]) } let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n") credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar")) credits.append(separator) credits.append(makeLink("Website", urlString: "https://codexbar.app")) credits.append(separator) credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete")) credits.append(separator) credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me")) if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) { var builtLine = "Built \(formatted)" if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" { builtLine += " (\(gitCommit)" #if DEBUG builtLine += " DEBUG BUILD" #endif builtLine += ")" } credits.append(NSAttributedString(string: "\n\(builtLine)", attributes: [ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .foregroundColor: NSColor.secondaryLabelColor, ])) } let options: [NSApplication.AboutPanelOptionKey: Any] = [ .applicationName: "CodexBar", .applicationVersion: versionString, .version: versionString, .credits: credits, .applicationIcon: (NSApplication.shared.applicationIconImage ?? NSImage()) as Any, ] NSApp.orderFrontStandardAboutPanel(options: options) // Remove the focus ring around the app icon in the standard About panel for a cleaner look. if let aboutPanel = NSApp.windows.first(where: { $0.className.contains("About") }) { removeFocusRings(in: aboutPanel.contentView) } } private func formattedBuildTimestamp(_ timestamp: String) -> String? { let parser = ISO8601DateFormatter() parser.formatOptions = [.withInternetDateTime] guard let date = parser.date(from: timestamp) else { return timestamp } let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short formatter.locale = .current return formatter.string(from: date) } @MainActor private func removeFocusRings(in view: NSView?) { guard let view else { return } if let imageView = view as? NSImageView { imageView.focusRingType = .none } for subview in view.subviews { removeFocusRings(in: subview) } } ================================================ FILE: Sources/CodexBar/AppNotifications.swift ================================================ import CodexBarCore import Foundation @preconcurrency import UserNotifications @MainActor final class AppNotifications { static let shared = AppNotifications() private let centerProvider: @Sendable () -> UNUserNotificationCenter private let logger = CodexBarLog.logger(LogCategories.notifications) private var authorizationTask: Task? init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) { self.centerProvider = centerProvider } func requestAuthorizationOnStartup() { guard !Self.isRunningUnderTests else { return } _ = self.ensureAuthorizationTask() } func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) { guard !Self.isRunningUnderTests else { return } let center = self.centerProvider() let logger = self.logger Task { @MainActor in let granted = await self.ensureAuthorized() guard granted else { logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) return } let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.badge = badge let request = UNNotificationRequest( identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)", content: content, trigger: nil) logger.info("posting", metadata: ["prefix": idPrefix]) do { try await center.add(request) } catch { let errorText = String(describing: error) logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) } } } // MARK: - Private private func ensureAuthorizationTask() -> Task { if let authorizationTask { return authorizationTask } let task = Task { @MainActor in await self.requestAuthorization() } self.authorizationTask = task return task } private func ensureAuthorized() async -> Bool { await self.ensureAuthorizationTask().value } private func requestAuthorization() async -> Bool { if let existing = await self.notificationAuthorizationStatus() { if existing == .authorized || existing == .provisional { return true } if existing == .denied { return false } } let center = self.centerProvider() return await withCheckedContinuation { continuation in center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in continuation.resume(returning: granted) } } } private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? { let center = self.centerProvider() return await withCheckedContinuation { continuation in center.getNotificationSettings { settings in continuation.resume(returning: settings.authorizationStatus) } } } private static var isRunningUnderTests: Bool { // Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from // the test target can make NSClassFromString("XCTestCase") return nil. If we're not // running inside an app bundle, treat it as "tests/headless" to avoid crashes when // accessing UNUserNotificationCenter. if Bundle.main.bundleURL.pathExtension != "app" { return true } let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil } } ================================================ FILE: Sources/CodexBar/ClaudeLoginRunner.swift ================================================ import CodexBarCore import Darwin import Foundation struct ClaudeLoginRunner { enum Phase { case requesting case waitingBrowser } struct Result { enum Outcome { case success case timedOut case failed(status: Int32) case missingBinary case launchFailed(String) } let outcome: Outcome let output: String let authLink: String? } static func run(timeout: TimeInterval = 120, onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { await Task(priority: .userInitiated) { onPhaseChange(.requesting) do { let runResult = try self.runPTY(timeout: timeout, onPhaseChange: onPhaseChange) let link = self.firstLink(in: runResult.output) if let link { return Result(outcome: .success, output: runResult.output, authLink: link) } return Result(outcome: .timedOut, output: runResult.output, authLink: nil) } catch LoginError.binaryNotFound { return Result(outcome: .missingBinary, output: "", authLink: nil) } catch let LoginError.timedOut(text) { return Result(outcome: .timedOut, output: text, authLink: self.firstLink(in: text)) } catch let LoginError.failed(status, text) { return Result(outcome: .failed(status: status), output: text, authLink: self.firstLink(in: text)) } catch { return Result(outcome: .launchFailed(error.localizedDescription), output: "", authLink: nil) } }.value } // MARK: - PTY runner private enum LoginError: Error { case binaryNotFound case timedOut(text: String) case failed(status: Int32, text: String) case launchFailed(String) } private struct PTYRunResult { let output: String } private static func runPTY( timeout: TimeInterval, onPhaseChange: @escaping @Sendable (Phase) -> Void) throws -> PTYRunResult { let runner = TTYCommandRunner() var options = TTYCommandRunner.Options(rows: 50, cols: 160, timeout: timeout) options.extraArgs = ["/login"] options.stopOnURL = false // keep running until CLI confirms options.stopOnSubstrings = ["Successfully logged in", "Login successful", "Logged in successfully"] options.sendEnterEvery = 1.0 options.settleAfterStop = 0.35 do { let result = try runner.run( binary: "claude", send: "", options: options, onURLDetected: { onPhaseChange(.waitingBrowser) }) return PTYRunResult(output: result.text) } catch TTYCommandRunner.Error.binaryNotFound { throw LoginError.binaryNotFound } catch TTYCommandRunner.Error.timedOut { throw LoginError.timedOut(text: "") } catch let TTYCommandRunner.Error.launchFailed(msg) { throw LoginError.launchFailed(msg) } catch { throw LoginError.launchFailed(error.localizedDescription) } } private static func firstLink(in text: String) -> String? { let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let nsRange = NSRange(text.startIndex..\"'").contains(last) { url.unicodeScalars.removeLast() } return url } } ================================================ FILE: Sources/CodexBar/CodexLoginRunner.swift ================================================ import CodexBarCore import Darwin import Foundation struct CodexLoginRunner { struct Result { enum Outcome { case success case timedOut case failed(status: Int32) case missingBinary case launchFailed(String) } let outcome: Outcome let output: String } static func run(timeout: TimeInterval = 120) async -> Result { await Task(priority: .userInitiated) { var env = ProcessInfo.processInfo.environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .tty, .nodeTooling], env: env, loginPATH: LoginShellPathCache.shared.current) guard let executable = BinaryLocator.resolveCodexBinary( env: env, loginPATH: LoginShellPathCache.shared.current) else { return Result(outcome: .missingBinary, output: "") } let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [executable, "login"] process.environment = env let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr var processGroup: pid_t? do { try process.run() processGroup = self.attachProcessGroup(process) } catch { return Result(outcome: .launchFailed(error.localizedDescription), output: "") } let timedOut = await self.wait(for: process, timeout: timeout) if timedOut { self.terminate(process, processGroup: processGroup) } let output = await self.combinedOutput(stdout: stdout, stderr: stderr) if timedOut { return Result(outcome: .timedOut, output: output) } let status = process.terminationStatus if status == 0 { return Result(outcome: .success, output: output) } return Result(outcome: .failed(status: status), output: output) }.value } private static func wait(for process: Process, timeout: TimeInterval) async -> Bool { await withTaskGroup(of: Bool.self) { group -> Bool in group.addTask { process.waitUntilExit() return false } group.addTask { let nanos = UInt64(max(0, timeout) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) return true } let result = await group.next() ?? false group.cancelAll() return result } } private static func terminate(_ process: Process, processGroup: pid_t?) { if let pgid = processGroup { kill(-pgid, SIGTERM) } if process.isRunning { process.terminate() } let deadline = Date().addingTimeInterval(2.0) while process.isRunning, Date() < deadline { usleep(100_000) } if process.isRunning { if let pgid = processGroup { kill(-pgid, SIGKILL) } kill(process.processIdentifier, SIGKILL) } } private static func attachProcessGroup(_ process: Process) -> pid_t? { let pid = process.processIdentifier return setpgid(pid, pid) == 0 ? pid : nil } private static func combinedOutput(stdout: Pipe, stderr: Pipe) async -> String { async let out = self.readToEnd(stdout) async let err = self.readToEnd(stderr) let stdoutText = await out let stderrText = await err let merged: String = if !stdoutText.isEmpty, !stderrText.isEmpty { [stdoutText, stderrText].joined(separator: "\n") } else { stdoutText + stderrText } let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines) let limited = trimmed.prefix(4000) return limited.isEmpty ? "No output captured." : String(limited) } private static func readToEnd(_ pipe: Pipe, timeout: TimeInterval = 3.0) async -> String { await withTaskGroup(of: String?.self) { group -> String in group.addTask { if #available(macOS 13.0, *) { if let data = try? pipe.fileHandleForReading.readToEnd() { return self.decode(data) } } let data = pipe.fileHandleForReading.readDataToEndOfFile() return Self.decode(data) } group.addTask { let nanos = UInt64(max(0, timeout) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) return nil } let result = await group.next() group.cancelAll() if let result, let text = result { return text } return "" } } private static func decode(_ data: Data) -> String { guard let text = String(data: data, encoding: .utf8) else { return "" } return text } } ================================================ FILE: Sources/CodexBar/CodexbarApp.swift ================================================ import AppKit import CodexBarCore import KeyboardShortcuts import Observation import QuartzCore import Security import SwiftUI @main struct CodexBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var settings: SettingsStore @State private var store: UsageStore private let preferencesSelection: PreferencesSelection private let account: AccountInfo init() { let env = ProcessInfo.processInfo.environment let storedLevel = CodexBarLog.parseLevel(UserDefaults.standard.string(forKey: "debugLogLevel")) ?? .verbose let level = CodexBarLog.parseLevel(env["CODEXBAR_LOG_LEVEL"]) ?? storedLevel CodexBarLog.bootstrapIfNeeded(.init( destination: .oslog(subsystem: "com.steipete.codexbar"), level: level, json: false)) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String ?? "unknown" let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String ?? "unknown" CodexBarLog.logger(LogCategories.app).info( "CodexBar starting", metadata: [ "version": version, "build": build, "git": gitCommit, "built": buildTimestamp, ]) KeychainAccessGate.isDisabled = UserDefaults.standard.bool(forKey: "debugDisableKeychainAccess") KeychainPromptCoordinator.install() let preferencesSelection = PreferencesSelection() let settings = SettingsStore() let fetcher = UsageFetcher() let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL) let account = fetcher.loadAccountInfo() let store = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: settings) self.preferencesSelection = preferencesSelection _settings = State(wrappedValue: settings) _store = State(wrappedValue: store) self.account = account CodexBarLog.setLogLevel(settings.debugLogLevel) self.appDelegate.configure( store: store, settings: settings, account: account, selection: preferencesSelection) } @SceneBuilder var body: some Scene { // Hidden 1×1 window to keep SwiftUI's lifecycle alive so `Settings` scene // shows the native toolbar tabs even though the UI is AppKit-based. WindowGroup("CodexBarLifecycleKeepalive") { HiddenWindowView() } .defaultSize(width: 20, height: 20) .windowStyle(.hiddenTitleBar) Settings { PreferencesView( settings: self.settings, store: self.store, updater: self.appDelegate.updaterController, selection: self.preferencesSelection) } .defaultSize(width: PreferencesTab.general.preferredWidth, height: PreferencesTab.general.preferredHeight) .windowResizability(.contentSize) } private func openSettings(tab: PreferencesTab) { self.preferencesSelection.tab = tab NSApp.activate(ignoringOtherApps: true) _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) } } // MARK: - Updater abstraction @MainActor protocol UpdaterProviding: AnyObject { var automaticallyChecksForUpdates: Bool { get set } var automaticallyDownloadsUpdates: Bool { get set } var isAvailable: Bool { get } var unavailableReason: String? { get } var updateStatus: UpdateStatus { get } func checkForUpdates(_ sender: Any?) } /// No-op updater used for debug builds and non-bundled runs to suppress Sparkle dialogs. final class DisabledUpdaterController: UpdaterProviding { var automaticallyChecksForUpdates: Bool = false var automaticallyDownloadsUpdates: Bool = false let isAvailable: Bool = false let unavailableReason: String? let updateStatus = UpdateStatus() init(unavailableReason: String? = nil) { self.unavailableReason = unavailableReason } func checkForUpdates(_ sender: Any?) {} } @MainActor @Observable final class UpdateStatus { static let disabled = UpdateStatus() var isUpdateReady: Bool init(isUpdateReady: Bool = false) { self.isUpdateReady = isUpdateReady } } #if canImport(Sparkle) && ENABLE_SPARKLE import Sparkle @MainActor final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate { private lazy var controller = SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) let updateStatus = UpdateStatus() let unavailableReason: String? = nil init(savedAutoUpdate: Bool) { super.init() let updater = self.controller.updater updater.automaticallyChecksForUpdates = savedAutoUpdate updater.automaticallyDownloadsUpdates = savedAutoUpdate self.controller.startUpdater() } var automaticallyChecksForUpdates: Bool { get { self.controller.updater.automaticallyChecksForUpdates } set { self.controller.updater.automaticallyChecksForUpdates = newValue } } var automaticallyDownloadsUpdates: Bool { get { self.controller.updater.automaticallyDownloadsUpdates } set { self.controller.updater.automaticallyDownloadsUpdates = newValue } } var isAvailable: Bool { true } func checkForUpdates(_ sender: Any?) { self.controller.checkForUpdates(sender) } nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { Task { @MainActor in self.updateStatus.isUpdateReady = true } } nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { Task { @MainActor in self.updateStatus.isUpdateReady = false } } nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { Task { @MainActor in self.updateStatus.isUpdateReady = false } } nonisolated func updater( _ updater: SPUUpdater, userDidMake choice: SPUUserUpdateChoice, forUpdate updateItem: SUAppcastItem, state: SPUUserUpdateState) { let downloaded = state.stage == .downloaded Task { @MainActor in switch choice { case .install, .skip: self.updateStatus.isUpdateReady = false case .dismiss: self.updateStatus.isUpdateReady = downloaded @unknown default: self.updateStatus.isUpdateReady = false } } } nonisolated func allowedChannels(for updater: SPUUpdater) -> Set { UpdateChannel.current.allowedSparkleChannels } } private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, let code = staticCode else { return false } var infoCF: CFDictionary? guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, let info = infoCF as? [String: Any], let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], let leaf = certs.first else { return false } if let summary = SecCertificateCopySubjectSummary(leaf) as String? { return summary.hasPrefix("Developer ID Application:") } return false } @MainActor private func makeUpdaterController() -> UpdaterProviding { let bundleURL = Bundle.main.bundleURL let isBundledApp = bundleURL.pathExtension == "app" guard isBundledApp else { return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.") } if InstallOrigin.isHomebrewCask(appBundleURL: bundleURL) { return DisabledUpdaterController( unavailableReason: "Updates managed by Homebrew. Run: brew upgrade --cask steipete/tap/codexbar") } guard isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.") } let defaults = UserDefaults.standard let autoUpdateKey = "autoUpdateEnabled" // Default to true for first launch; fall back to saved preference thereafter. let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) } #else private func makeUpdaterController() -> UpdaterProviding { DisabledUpdaterController() } #endif @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let updaterController: UpdaterProviding = makeUpdaterController() private var statusController: StatusItemControlling? private var store: UsageStore? private var settings: SettingsStore? private var account: AccountInfo? private var preferencesSelection: PreferencesSelection? func configure(store: UsageStore, settings: SettingsStore, account: AccountInfo, selection: PreferencesSelection) { self.store = store self.settings = settings self.account = account self.preferencesSelection = selection } func applicationWillFinishLaunching(_ notification: Notification) { self.configureAppIconForMacOSVersion() } func applicationDidFinishLaunching(_ notification: Notification) { AppNotifications.shared.requestAuthorizationOnStartup() self.ensureStatusController() KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in Task { @MainActor [weak self] in self?.statusController?.openMenuFromShortcut() } } } func applicationWillTerminate(_ notification: Notification) { TTYCommandRunner.terminateActiveProcessesForAppShutdown() } /// Use the classic (non-Liquid Glass) app icon on macOS versions before 26. private func configureAppIconForMacOSVersion() { if #unavailable(macOS 26) { self.applyClassicAppIcon() } } private func applyClassicAppIcon() { guard let classicIcon = Self.loadClassicIcon() else { return } NSApp.applicationIconImage = classicIcon } private static func loadClassicIcon() -> NSImage? { guard let url = self.classicIconURL(), let image = NSImage(contentsOf: url) else { return nil } return image } private static func classicIconURL() -> URL? { Bundle.main.url(forResource: "Icon-classic", withExtension: "icns") } private func ensureStatusController() { if self.statusController != nil { return } if let store, let settings, let account, let selection = self.preferencesSelection { self.statusController = StatusItemController.factory( store, settings, account, self.updaterController, selection) return } // Defensive fallback: this should not be hit in normal app lifecycle. CodexBarLog.logger(LogCategories.app) .error("StatusItemController fallback path used; settings/store mismatch likely.") assertionFailure("StatusItemController fallback path used; check app lifecycle wiring.") let fallbackSettings = SettingsStore() let fetcher = UsageFetcher() let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL) let fallbackAccount = fetcher.loadAccountInfo() let fallbackStore = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: fallbackSettings) self.statusController = StatusItemController.factory( fallbackStore, fallbackSettings, fallbackAccount, self.updaterController, PreferencesSelection()) } } ================================================ FILE: Sources/CodexBar/Config/CodexBarConfigMigrator.swift ================================================ import CodexBarCore import Foundation struct CodexBarConfigMigrator { struct LegacyStores { let zaiTokenStore: any ZaiTokenStoring let syntheticTokenStore: any SyntheticTokenStoring let codexCookieStore: any CookieHeaderStoring let claudeCookieStore: any CookieHeaderStoring let cursorCookieStore: any CookieHeaderStoring let opencodeCookieStore: any CookieHeaderStoring let factoryCookieStore: any CookieHeaderStoring let minimaxCookieStore: any MiniMaxCookieStoring let minimaxAPITokenStore: any MiniMaxAPITokenStoring let kimiTokenStore: any KimiTokenStoring let kimiK2TokenStore: any KimiK2TokenStoring let augmentCookieStore: any CookieHeaderStoring let ampCookieStore: any CookieHeaderStoring let copilotTokenStore: any CopilotTokenStoring let tokenAccountStore: any ProviderTokenAccountStoring } private struct MigrationState { var didUpdate = false var sawLegacySecrets = false var sawLegacyAccounts = false } static func loadOrMigrate( configStore: CodexBarConfigStore, userDefaults: UserDefaults, stores: LegacyStores) -> CodexBarConfig { let log = CodexBarLog.logger(LogCategories.configMigration) let existing = try? configStore.load() var config = (existing ?? CodexBarConfig.makeDefault()).normalized() var state = MigrationState() if existing == nil { self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state) } self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state) self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state) self.migrateLegacyAccounts(stores: stores, config: &config, state: &state) if state.didUpdate { do { try configStore.save(config) } catch { log.error("Failed to persist config: \(error)") } } if state.sawLegacySecrets || state.sawLegacyAccounts { self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log) } return config.normalized() } private static func applyLegacyOrderAndToggles( userDefaults: UserDefaults, config: inout CodexBarConfig, state: inout MigrationState) { if let order = userDefaults.stringArray(forKey: "providerOrder"), !order.isEmpty { config = self.applyProviderOrder(order, config: config) state.didUpdate = true } let toggles = userDefaults.dictionary(forKey: "providerToggles") as? [String: Bool] ?? [:] if !toggles.isEmpty { config = self.applyProviderToggles(toggles, config: config) state.didUpdate = true } } private static func migrateLegacySecrets( userDefaults: UserDefaults, stores: LegacyStores, config: inout CodexBarConfig, state: inout MigrationState) { self.migrateTokenProviders( [ (.zai, stores.zaiTokenStore.loadToken), (.synthetic, stores.syntheticTokenStore.loadToken), (.copilot, stores.copilotTokenStore.loadToken), (.kimik2, stores.kimiK2TokenStore.loadToken), ], config: &config, state: &state) self.migrateCookieProviders( [ (.codex, stores.codexCookieStore.loadCookieHeader), (.claude, stores.claudeCookieStore.loadCookieHeader), (.cursor, stores.cursorCookieStore.loadCookieHeader), (.factory, stores.factoryCookieStore.loadCookieHeader), (.augment, stores.augmentCookieStore.loadCookieHeader), (.amp, stores.ampCookieStore.loadCookieHeader), ], config: &config, state: &state) self.migrateMiniMax(userDefaults: userDefaults, stores: stores, config: &config, state: &state) self.migrateKimi(userDefaults: userDefaults, stores: stores, config: &config, state: &state) self.migrateOpenCode(userDefaults: userDefaults, stores: stores, config: &config, state: &state) } private static func applyLegacyCookieSources( userDefaults: UserDefaults, config: inout CodexBarConfig, state: inout MigrationState) { let sources: [(UsageProvider, String)] = [ (.codex, "codexCookieSource"), (.claude, "claudeCookieSource"), (.cursor, "cursorCookieSource"), (.opencode, "opencodeCookieSource"), (.factory, "factoryCookieSource"), (.minimax, "minimaxCookieSource"), (.kimi, "kimiCookieSource"), (.augment, "augmentCookieSource"), (.amp, "ampCookieSource"), ] for (provider, key) in sources { guard let raw = userDefaults.string(forKey: key), let source = ProviderCookieSource(rawValue: raw) else { continue } self.updateProvider(provider, config: &config, state: &state) { entry in guard entry.cookieSource == nil else { return false } entry.cookieSource = source return true } } if userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool == false { self.updateProvider(.codex, config: &config, state: &state) { entry in guard entry.cookieSource == nil else { return false } entry.cookieSource = .off return true } } } private static func migrateTokenProviders( _ providers: [(UsageProvider, () throws -> String?)], config: inout CodexBarConfig, state: inout MigrationState) { for (provider, loader) in providers { let token = try? loader() if token != nil { state.sawLegacySecrets = true } self.updateProvider(provider, config: &config, state: &state) { entry in self.setIfEmpty(&entry.apiKey, token) } } } private static func migrateCookieProviders( _ providers: [(UsageProvider, () throws -> String?)], config: inout CodexBarConfig, state: inout MigrationState) { for (provider, loader) in providers { let header = try? loader() if header != nil { state.sawLegacySecrets = true } self.updateProvider(provider, config: &config, state: &state) { entry in self.setIfEmpty(&entry.cookieHeader, header) } } } private static func migrateMiniMax( userDefaults: UserDefaults, stores: LegacyStores, config: inout CodexBarConfig, state: inout MigrationState) { let token = try? stores.minimaxAPITokenStore.loadToken() let header = try? stores.minimaxCookieStore.loadCookieHeader() if token != nil || header != nil { state.sawLegacySecrets = true } let regionRaw = userDefaults.string(forKey: "minimaxAPIRegion") self.updateProvider(.minimax, config: &config, state: &state) { entry in var changed = false changed = self.setIfEmpty(&entry.apiKey, token) || changed if let regionRaw, !regionRaw.isEmpty, entry.region == nil { entry.region = regionRaw changed = true } changed = self.setIfEmpty(&entry.cookieHeader, header) || changed return changed } } private static func migrateKimi( userDefaults: UserDefaults, stores: LegacyStores, config: inout CodexBarConfig, state: inout MigrationState) { var token = try? stores.kimiTokenStore.loadToken() if token?.isEmpty ?? true { token = userDefaults.string(forKey: "kimiManualCookieHeader") } if token != nil { state.sawLegacySecrets = true } self.updateProvider(.kimi, config: &config, state: &state) { entry in self.setIfEmpty(&entry.cookieHeader, token) } } private static func migrateOpenCode( userDefaults: UserDefaults, stores: LegacyStores, config: inout CodexBarConfig, state: inout MigrationState) { let header = try? stores.opencodeCookieStore.loadCookieHeader() if header != nil { state.sawLegacySecrets = true } let workspaceID = userDefaults.string(forKey: "opencodeWorkspaceID") self.updateProvider(.opencode, config: &config, state: &state) { entry in var changed = false changed = self.setIfEmpty(&entry.cookieHeader, header) || changed if let workspaceID, !workspaceID.isEmpty, entry.workspaceID == nil { entry.workspaceID = workspaceID changed = true } return changed } } private static func migrateLegacyAccounts( stores: LegacyStores, config: inout CodexBarConfig, state: inout MigrationState) { guard let accounts = try? stores.tokenAccountStore.loadAccounts(), !accounts.isEmpty else { return } state.sawLegacyAccounts = true for (provider, data) in accounts where !data.accounts.isEmpty { self.updateProvider(provider, config: &config, state: &state) { entry in guard entry.tokenAccounts == nil else { return false } entry.tokenAccounts = data return true } } } private static func updateProvider( _ provider: UsageProvider, config: inout CodexBarConfig, state: inout MigrationState, mutate: (inout ProviderConfig) -> Bool) { guard let index = config.providers.firstIndex(where: { $0.id == provider }) else { return } var entry = config.providers[index] let changed = mutate(&entry) if changed { config.providers[index] = entry state.didUpdate = true } } private static func setIfEmpty(_ value: inout String?, _ replacement: String?) -> Bool { let cleaned = replacement?.trimmingCharacters(in: .whitespacesAndNewlines) guard let cleaned, !cleaned.isEmpty else { return false } if value == nil || value?.isEmpty == true { value = cleaned return true } return false } private static func clearLegacyStores( stores: LegacyStores, sawAccounts: Bool, log: CodexBarLogger) { do { try stores.zaiTokenStore.storeToken(nil) try stores.syntheticTokenStore.storeToken(nil) try stores.copilotTokenStore.storeToken(nil) try stores.minimaxAPITokenStore.storeToken(nil) try stores.kimiTokenStore.storeToken(nil) try stores.kimiK2TokenStore.storeToken(nil) try stores.codexCookieStore.storeCookieHeader(nil) try stores.claudeCookieStore.storeCookieHeader(nil) try stores.cursorCookieStore.storeCookieHeader(nil) try stores.opencodeCookieStore.storeCookieHeader(nil) try stores.factoryCookieStore.storeCookieHeader(nil) try stores.minimaxCookieStore.storeCookieHeader(nil) try stores.augmentCookieStore.storeCookieHeader(nil) try stores.ampCookieStore.storeCookieHeader(nil) } catch { log.error("Failed to clear legacy secrets: \(error)") } if sawAccounts { let legacyURL = FileTokenAccountStore.defaultURL() if FileManager.default.fileExists(atPath: legacyURL.path) { try? FileManager.default.removeItem(at: legacyURL) } } } private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig { let configsByID = Dictionary(uniqueKeysWithValues: config.providers.map { ($0.id, $0) }) var seen: Set = [] var ordered: [ProviderConfig] = [] ordered.reserveCapacity(config.providers.count) for rawValue in raw { guard let provider = UsageProvider(rawValue: rawValue), let entry = configsByID[provider], !seen.contains(provider) else { continue } seen.insert(provider) ordered.append(entry) } for provider in UsageProvider.allCases where !seen.contains(provider) { ordered.append(configsByID[provider] ?? ProviderConfig(id: provider)) } var updated = config updated.providers = ordered return updated } private static func applyProviderToggles( _ toggles: [String: Bool], config: CodexBarConfig) -> CodexBarConfig { var updated = config for index in updated.providers.indices { let provider = updated.providers[index].id let meta = ProviderDescriptorRegistry.descriptor(for: provider).metadata if let value = toggles[meta.cliName] { updated.providers[index].enabled = value } } return updated } } ================================================ FILE: Sources/CodexBar/CookieHeaderStore.swift ================================================ import CodexBarCore import Foundation import Security protocol CookieHeaderStoring: Sendable { func loadCookieHeader() throws -> String? func storeCookieHeader(_ header: String?) throws } enum CookieHeaderStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainCookieHeaderStore: CookieHeaderStoring { private static let log = CodexBarLog.logger(LogCategories.cookieHeaderStore) private let service = "com.steipete.CodexBar" private let account: String private let promptKind: KeychainPromptContext.Kind // Cache to reduce keychain access frequency private nonisolated(unsafe) static var cache: [String: CachedValue] = [:] private static let cacheLock = NSLock() private static let cacheTTL: TimeInterval = 1800 // 30 minutes private struct CachedValue { let value: String? let timestamp: Date var isExpired: Bool { Date().timeIntervalSince(self.timestamp) > KeychainCookieHeaderStore.cacheTTL } } init(account: String, promptKind: KeychainPromptContext.Kind) { self.account = account self.promptKind = promptKind } func loadCookieHeader() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping cookie load") return nil } // Check cache first Self.cacheLock.lock() if let cached = Self.cache[self.account], !cached.isExpired { Self.cacheLock.unlock() Self.log.debug("Using cached cookie header for \(self.account)") return cached.value } Self.cacheLock.unlock() var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: self.promptKind, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() Self.cache[self.account] = CachedValue(value: nil, timestamp: Date()) Self.cacheLock.unlock() return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw CookieHeaderStoreError.keychainStatus(status) } guard let data = result as? Data else { throw CookieHeaderStoreError.invalidData } let header = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) let finalValue = (header?.isEmpty == false) ? header : nil // Cache the result Self.cacheLock.lock() Self.cache[self.account] = CachedValue(value: finalValue, timestamp: Date()) Self.cacheLock.unlock() return finalValue } func storeCookieHeader(_ header: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping cookie store") return } guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { try self.deleteIfPresent() // Invalidate cache Self.cacheLock.lock() Self.cache.removeValue(forKey: self.account) Self.cacheLock.unlock() return } guard CookieHeaderNormalizer.normalize(raw) != nil else { try self.deleteIfPresent() // Invalidate cache Self.cacheLock.lock() Self.cache.removeValue(forKey: self.account) Self.cacheLock.unlock() return } let data = raw.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() Self.cache[self.account] = CachedValue(value: raw, timestamp: Date()) Self.cacheLock.unlock() return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw CookieHeaderStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CookieHeaderStoreError.keychainStatus(addStatus) } // Update cache Self.cacheLock.lock() Self.cache[self.account] = CachedValue(value: raw, timestamp: Date()) Self.cacheLock.unlock() } private func deleteIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() Self.cache.removeValue(forKey: self.account) Self.cacheLock.unlock() return } Self.log.error("Keychain delete failed: \(status)") throw CookieHeaderStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/CopilotTokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol CopilotTokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum CopilotTokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainCopilotTokenStore: CopilotTokenStoring { private static let log = CodexBarLog.logger(LogCategories.copilotTokenStore) private let service = "com.steipete.CodexBar" private let account = "copilot-api-token" func loadToken() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token load") return nil } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .copilotToken, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw CopilotTokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw CopilotTokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let token, !token.isEmpty { return token } return nil } func storeToken(_ token: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token store") return } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw CopilotTokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CopilotTokenStoreError.keychainStatus(addStatus) } } private func deleteTokenIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw CopilotTokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/CostHistoryChartMenuView.swift ================================================ import Charts import CodexBarCore import SwiftUI @MainActor struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry private struct Point: Identifiable { let id: String let date: Date let costUSD: Double let totalTokens: Int? init(date: Date, costUSD: Double, totalTokens: Int?) { self.date = date self.costUSD = costUSD self.totalTokens = totalTokens self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" } } private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? private let width: CGFloat @State private var selectedDateKey: String? init(provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, width: CGFloat) { self.provider = provider self.daily = daily self.totalCostUSD = totalCostUSD self.width = width } var body: some View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { Text("No cost history data.") .font(.footnote) .foregroundStyle(.secondary) } else { Chart { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), y: .value("Cost", point.costUSD)) .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), yEnd: .value("Cap end", peak.costUSD)) .foregroundStyle(Color(nsColor: .systemYellow)) } } .chartYAxis(.hidden) .chartXAxis { AxisMarks(values: model.axisDates) { _ in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } .chartLegend(.hidden) .frame(height: 130) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { Rectangle() .fill(Self.selectionBandColor) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) } MouseLocationReader { location in self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } } } let detail = self.detailLines(model: model) VStack(alignment: .leading, spacing: 0) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) Text(detail.secondary ?? " ") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) .opacity(detail.secondary == nil ? 0 : 1) } } if let total = self.totalCostUSD { Text("Total (30d): \(UsageFormatter.usdString(total))") .font(.caption) .foregroundStyle(.secondary) } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } private struct Model { let points: [Point] let pointsByDateKey: [String: Point] let entriesByDateKey: [String: DailyEntry] let dateKeys: [(key: String, date: Date)] let axisDates: [Date] let barColor: Color let peakKey: String? let maxCostUSD: Double } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } var points: [Point] = [] points.reserveCapacity(sorted.count) var pointsByKey: [String: Point] = [:] pointsByKey.reserveCapacity(sorted.count) var entriesByKey: [String: DailyEntry] = [:] entriesByKey.reserveCapacity(sorted.count) var dateKeys: [(key: String, date: Date)] = [] dateKeys.reserveCapacity(sorted.count) var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) points.append(point) pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { peak = (entry.date, costUSD) } maxCostUSD = max(maxCostUSD, costUSD) } let axisDates: [Date] = { guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } return [first, last] }() let barColor = Self.barColor(for: provider) return Model( points: points, pointsByDateKey: pointsByKey, entriesByDateKey: entriesByKey, dateKeys: dateKeys, axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD) } private static func barColor(for provider: UsageProvider) -> Color { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = year comps.month = month comps.day = day comps.hour = 12 return comps.date } private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] guard let index = model.dateKeys.firstIndex(where: { $0.key == key }) else { return nil } let date = model.dateKeys[index].date guard let x = proxy.position(forX: date) else { return nil } func xForIndex(_ idx: Int) -> CGFloat? { guard idx >= 0, idx < model.dateKeys.count else { return nil } return proxy.position(forX: model.dateKeys[idx].date) } let xPrev = xForIndex(index - 1) let xNext = xForIndex(index + 1) let leftInPlot: CGFloat = if let xPrev { (xPrev + x) / 2 } else if let xNext { x - (xNext - x) / 2 } else { x - 8 } let rightInPlot: CGFloat = if let xNext { (xNext + x) / 2 } else if let xPrev { x + (x - xPrev) / 2 } else { x + 8 } let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } private func updateSelection( location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { guard let location else { if self.selectedDateKey != nil { self.selectedDateKey = nil } return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] guard plotFrame.contains(location) else { return } let xInPlot = location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDateKey(to: date, model: model) else { return } if self.selectedDateKey != nearest { self.selectedDateKey = nearest } } private func nearestDateKey(to date: Date, model: Model) -> String? { guard !model.dateKeys.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.dateKeys { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { if dist < cur.distance { best = (entry.key, dist) } } else { best = (entry.key, dist) } } return best?.key } private func detailLines(model: Model) -> (primary: String, secondary: String?) { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { return ("Hover a bar for details", nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) if let tokens = point.totalTokens { let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } let primary = "\(dayLabel): \(cost)" let secondary = self.topModelsText(key: key, model: model) return (primary, secondary) } private func topModelsText(key: String, model: Model) -> String? { guard let entry = model.entriesByDateKey[key] else { return nil } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } let parts = breakdown .compactMap { item -> String? in let name = UsageFormatter.modelDisplayName(item.modelName) guard let detail = UsageFormatter.modelCostDetail( item.modelName, costUSD: item.costUSD, totalTokens: item.totalTokens) else { return nil } return "\(name) \(detail)" } .prefix(3) guard !parts.isEmpty else { return nil } return "Top: \(parts.joined(separator: " · "))" } } ================================================ FILE: Sources/CodexBar/CreditsHistoryChartMenuView.swift ================================================ import Charts import CodexBarCore import SwiftUI @MainActor struct CreditsHistoryChartMenuView: View { private struct Point: Identifiable { let id: String let date: Date let creditsUsed: Double init(date: Date, creditsUsed: Double) { self.date = date self.creditsUsed = creditsUsed self.id = "\(Int(date.timeIntervalSince1970))-\(creditsUsed)" } } private let breakdown: [OpenAIDashboardDailyBreakdown] private let width: CGFloat @State private var selectedDayKey: String? init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) { self.breakdown = breakdown self.width = width } var body: some View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { Text("No credits history data.") .font(.footnote) .foregroundStyle(.secondary) } else { Chart { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), y: .value("Credits used", point.creditsUsed)) .foregroundStyle(Self.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), yEnd: .value("Cap end", peak.creditsUsed)) .foregroundStyle(Color(nsColor: .systemYellow)) } } .chartYAxis(.hidden) .chartXAxis { AxisMarks(values: model.axisDates) { _ in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } .chartLegend(.hidden) .frame(height: 130) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { Rectangle() .fill(Self.selectionBandColor) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) } MouseLocationReader { location in self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } } } let detail = self.detailLines(model: model) VStack(alignment: .leading, spacing: 0) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) Text(detail.secondary ?? " ") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) .opacity(detail.secondary == nil ? 0 : 1) } if let total = model.totalCreditsUsed { Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits") .font(.caption) .foregroundStyle(.secondary) } } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] let pointsByDayKey: [String: Point] let dayDates: [(dayKey: String, date: Date)] let selectableDayDates: [(dayKey: String, date: Date)] let axisDates: [Date] let peakKey: String? let totalCreditsUsed: Double? let maxCreditsUsed: Double } private static let barColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = breakdown.sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] points.reserveCapacity(sorted.count) var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:] breakdownByDayKey.reserveCapacity(sorted.count) var pointsByDayKey: [String: Point] = [:] pointsByDayKey.reserveCapacity(sorted.count) var dayDates: [(dayKey: String, date: Date)] = [] dayDates.reserveCapacity(sorted.count) var selectableDayDates: [(dayKey: String, date: Date)] = [] selectableDayDates.reserveCapacity(sorted.count) var totalCreditsUsed: Double = 0 var peak: (key: String, creditsUsed: Double)? var maxCreditsUsed: Double = 0 for day in sorted { guard let date = self.dateFromDayKey(day.day) else { continue } breakdownByDayKey[day.day] = day dayDates.append((dayKey: day.day, date: date)) totalCreditsUsed += day.totalCreditsUsed if day.totalCreditsUsed > 0 { let point = Point(date: date, creditsUsed: day.totalCreditsUsed) points.append(point) pointsByDayKey[day.day] = point selectableDayDates.append((dayKey: day.day, date: date)) if let cur = peak { if day.totalCreditsUsed > cur.creditsUsed { peak = (day.day, day.totalCreditsUsed) } } else { peak = (day.day, day.totalCreditsUsed) } maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed) } } let axisDates: [Date] = { guard let first = dayDates.first?.date, let last = dayDates.last?.date else { return [] } if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } return [first, last] }() return Model( points: points, breakdownByDayKey: breakdownByDayKey, pointsByDayKey: pointsByDayKey, dayDates: dayDates, selectableDayDates: selectableDayDates, axisDates: axisDates, peakKey: peak?.key, totalCreditsUsed: totalCreditsUsed > 0 ? totalCreditsUsed : nil, maxCreditsUsed: maxCreditsUsed) } private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = year comps.month = month comps.day = day comps.hour = 12 return comps.date } private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDayKey[key] } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDayKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil } let date = model.dayDates[index].date guard let x = proxy.position(forX: date) else { return nil } func xForIndex(_ idx: Int) -> CGFloat? { guard idx >= 0, idx < model.dayDates.count else { return nil } return proxy.position(forX: model.dayDates[idx].date) } let xPrev = xForIndex(index - 1) let xNext = xForIndex(index + 1) if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, y: plotFrame.origin.y, width: plotFrame.width, height: plotFrame.height) } let leftInPlot: CGFloat = if let xPrev { (xPrev + x) / 2 } else if let xNext { x - (xNext - x) / 2 } else { x - 8 } let rightInPlot: CGFloat = if let xNext { (xNext + x) / 2 } else if let xPrev { x + (x - xPrev) / 2 } else { x + 8 } let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } private func updateSelection( location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { guard let location else { if self.selectedDayKey != nil { self.selectedDayKey = nil } return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] guard plotFrame.contains(location) else { return } let xInPlot = location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDayKey(to: date, model: model) else { return } if self.selectedDayKey != nearest { self.selectedDayKey = nearest } } private func nearestDayKey(to date: Date, model: Model) -> String? { guard !model.selectableDayDates.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.selectableDayDates { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { if dist < cur.distance { best = (entry.dayKey, dist) } } else { best = (entry.dayKey, dist) } } return best?.key } private func detailLines(model: Model) -> (primary: String, secondary: String?) { guard let key = self.selectedDayKey, let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { return ("Hover a bar for details", nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { return ("\(dayLabel): \(total) credits", nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) return ("\(dayLabel): \(used) credits", first.service) } let services = day.services .sorted { lhs, rhs in if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service } return lhs.creditsUsed > rhs.creditsUsed } .prefix(3) .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") return ("\(dayLabel): \(total) credits", services) } } ================================================ FILE: Sources/CodexBar/CursorLoginRunner.swift ================================================ import AppKit import CodexBarCore import Foundation import WebKit /// Handles Cursor login flow using a WebKit-based browser window. /// Captures session cookies after successful authentication. @MainActor final class CursorLoginRunner: NSObject { enum Phase { case loading case waitingLogin case success case failed(String) } struct Result { enum Outcome { case success case cancelled case failed(String) } let outcome: Outcome let email: String? } private let browserDetection: BrowserDetection private var webView: WKWebView? private var window: NSWindow? private var continuation: CheckedContinuation? private var phaseCallback: ((Phase) -> Void)? private var hasCompletedLogin = false private let logger = CodexBarLog.logger(LogCategories.cursorLogin) private static let dashboardURL = URL(string: "https://cursor.com/dashboard")! private static let loginURLPattern = "authenticator.cursor.sh" init(browserDetection: BrowserDetection) { self.browserDetection = browserDetection super.init() } /// Runs the Cursor login flow in a browser window. /// Returns the result after the user completes login or cancels. func run(onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { // Keep this instance alive during the flow. WebKitTeardown.retain(self) self.phaseCallback = onPhaseChange onPhaseChange(.loading) self.logger.info("Cursor login started") return await withCheckedContinuation { continuation in self.continuation = continuation self.setupWindow() } } private func setupWindow() { // Use a non-persistent store for the login flow; cookies are persisted explicitly. let config = WKWebViewConfiguration() config.websiteDataStore = .nonPersistent() let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 480, height: 640), configuration: config) webView.navigationDelegate = self self.webView = webView // Create window let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.title = "Cursor Login" window.contentView = webView window.center() window.delegate = self window.makeKeyAndOrderFront(nil) self.window = window self.logger.info("Cursor login window opened") // Navigate to dashboard (will redirect to login if not authenticated) let request = URLRequest(url: Self.dashboardURL) webView.load(request) } private func complete(with result: Result) { guard let continuation = self.continuation else { return } self.continuation = nil self.logger.info("Cursor login completed", metadata: ["outcome": "\(result.outcome)"]) self.scheduleCleanup() continuation.resume(returning: result) } private func scheduleCleanup() { self.logger.info("Cursor login window closing") WebKitTeardown.scheduleCleanup(owner: self, window: self.window, webView: self.webView) } private func captureSessionCookies() async { guard let webView = self.webView else { return } let dataStore = webView.configuration.websiteDataStore let cookies = await dataStore.httpCookieStore.allCookies() // Filter for cursor.com cookies let cursorCookies = cookies.filter { cookie in cookie.domain.contains("cursor.com") || cookie.domain.contains("cursor.sh") } guard !cursorCookies.isEmpty else { self.phaseCallback?(.failed("No session cookies found")) self.logger.warning("Cursor login failed: no session cookies found") self.complete(with: Result(outcome: .failed("No session cookies found"), email: nil)) return } // Save cookies to the session store await CursorSessionStore.shared.setCookies(cursorCookies) self.logger.info("Cursor session cookies captured", metadata: ["count": "\(cursorCookies.count)"]) // Try to get user email let email = await self.fetchUserEmail() self.hasCompletedLogin = true self.phaseCallback?(.success) self.complete(with: Result(outcome: .success, email: email)) } private func fetchUserEmail() async -> String? { do { let probe = CursorStatusProbe(browserDetection: self.browserDetection) let snapshot = try await probe.fetch() return snapshot.accountEmail } catch { return nil } } } // MARK: - WKNavigationDelegate extension CursorLoginRunner: WKNavigationDelegate { nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { Task { @MainActor in guard let url = webView.url else { return } let urlString = url.absoluteString // Check if on login page if urlString.contains(Self.loginURLPattern) { self.phaseCallback?(.waitingLogin) return } // Check if on dashboard (login successful) if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { await self.captureSessionCookies() } } } nonisolated func webView( _ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { Task { @MainActor in guard let url = webView.url else { return } let urlString = url.absoluteString // Detect redirect to dashboard after login if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { // Wait a moment for cookies to be set, then capture try? await Task.sleep(nanoseconds: 500_000_000) await self.captureSessionCookies() } } } nonisolated func webView( _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { Task { @MainActor in self.phaseCallback?(.failed(error.localizedDescription)) self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) } } nonisolated func webView( _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { Task { @MainActor in // Ignore cancelled navigations (common during redirects) let nsError = error as NSError if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } self.phaseCallback?(.failed(error.localizedDescription)) self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) } } } // MARK: - NSWindowDelegate extension CursorLoginRunner: NSWindowDelegate { nonisolated func windowWillClose(_ notification: Notification) { Task { @MainActor in if !self.hasCompletedLogin { self.logger.info("Cursor login cancelled") self.complete(with: Result(outcome: .cancelled, email: nil)) } } } } ================================================ FILE: Sources/CodexBar/Date+RelativeDescription.swift ================================================ import Foundation enum RelativeTimeFormatters { @MainActor static let full: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full return formatter }() } extension Date { @MainActor func relativeDescription(now: Date = .now) -> String { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { return "just now" } return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) } } ================================================ FILE: Sources/CodexBar/DisplayLink.swift ================================================ import AppKit import CoreVideo import Observation import QuartzCore /// Minimal display link driver using NSScreen.displayLink on macOS 15+, /// and CVDisplayLink on macOS 14. /// Publishes ticks on the main thread at the requested frame rate. @MainActor @Observable final class DisplayLinkDriver { // Published counter used to drive SwiftUI updates. var tick: Int = 0 private var displayLink: CADisplayLink? private var cvDisplayLink: CVDisplayLink? private var targetInterval: CFTimeInterval = 1.0 / 60.0 private var lastTickTimestamp: CFTimeInterval = 0 private let onTick: (() -> Void)? init(onTick: (() -> Void)? = nil) { self.onTick = onTick } func start(fps: Double = 12) { guard self.displayLink == nil, self.cvDisplayLink == nil else { return } let clampedFps = max(fps, 1) self.targetInterval = 1.0 / clampedFps self.lastTickTimestamp = 0 if #available(macOS 15, *), let screen = NSScreen.main { // NSScreen.displayLink is macOS 15+ only. let displayLink = screen.displayLink(target: self, selector: #selector(self.step)) let rate = Float(clampedFps) displayLink.preferredFrameRateRange = CAFrameRateRange( minimum: rate, maximum: rate, preferred: rate) displayLink.add(to: .main, forMode: .common) self.displayLink = displayLink } else { self.startCVDisplayLink() } } func stop() { self.displayLink?.invalidate() self.displayLink = nil if let cvDisplayLink = self.cvDisplayLink { CVDisplayLinkStop(cvDisplayLink) } self.cvDisplayLink = nil } @objc private func step(_: AnyObject) { self.handleTick() } private func handleTick() { let now = CACurrentMediaTime() if self.lastTickTimestamp > 0, now - self.lastTickTimestamp < self.targetInterval { return } self.lastTickTimestamp = now // Safe on main runloop; drives SwiftUI updates. self.tick &+= 1 self.onTick?() } private func startCVDisplayLink() { var link: CVDisplayLink? if CVDisplayLinkCreateWithActiveCGDisplays(&link) != kCVReturnSuccess { return } guard let link else { return } let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, userInfo in guard let userInfo else { return kCVReturnSuccess } let driver = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() driver.scheduleTick() return kCVReturnSuccess } CVDisplayLinkSetOutputCallback(link, callback, Unmanaged.passUnretained(self).toOpaque()) CVDisplayLinkStart(link) self.cvDisplayLink = link } private nonisolated func scheduleTick() { Task { @MainActor [weak self] in self?.handleTick() } } deinit { Task { @MainActor [weak self] in self?.stop() } } } ================================================ FILE: Sources/CodexBar/GeminiLoginRunner.swift ================================================ import AppKit import CodexBarCore import Foundation enum GeminiLoginRunner { private static let geminiConfigDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".gemini") private static let credentialsFile = "oauth_creds.json" private static func clearCredentials() { let fm = FileManager.default let filesToDelete = [credentialsFile, "google_accounts.json"] for file in filesToDelete { let path = self.geminiConfigDir.appendingPathComponent(file) try? fm.removeItem(at: path) } } struct Result { enum Outcome { case success case missingBinary case launchFailed(String) } let outcome: Outcome } static func run(onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result { await Task(priority: .userInitiated) { let env = ProcessInfo.processInfo.environment guard let binary = BinaryLocator.resolveGeminiBinary( env: env, loginPATH: LoginShellPathCache.shared.current) else { return Result(outcome: .missingBinary) } // Clear existing credentials before auth (enables clean account switch) Self.clearCredentials() // Start watching for credentials file to be created if let callback = onCredentialsCreated { Self.watchForCredentials(callback: callback) } // Create a temporary shell script that runs gemini (auto-prompts for auth when no creds) let scriptContent = """ #!/bin/bash cd ~ "\(binary)" """ let tempDir = FileManager.default.temporaryDirectory let scriptURL = tempDir.appendingPathComponent("gemini_login_\(UUID().uuidString).command") do { try scriptContent.write(to: scriptURL, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) let config = NSWorkspace.OpenConfiguration() config.activates = true try await NSWorkspace.shared.open(scriptURL, configuration: config) // Clean up script after Terminal has time to read it let scriptPath = scriptURL.path DispatchQueue.global().asyncAfter(deadline: .now() + 10) { try? FileManager.default.removeItem(atPath: scriptPath) } return Result(outcome: .success) } catch { return Result(outcome: .launchFailed(error.localizedDescription)) } }.value } /// Watch for credentials file to be created, then call callback once private static func watchForCredentials(callback: @escaping @Sendable () -> Void, timeout: TimeInterval = 300) { let credsPath = self.geminiConfigDir.appendingPathComponent(self.credentialsFile).path DispatchQueue.global(qos: .utility).async { let startTime = Date() while Date().timeIntervalSince(startTime) < timeout { if FileManager.default.fileExists(atPath: credsPath) { // Small delay to ensure file is fully written Thread.sleep(forTimeInterval: 0.5) callback() return } Thread.sleep(forTimeInterval: 1.0) } } } } ================================================ FILE: Sources/CodexBar/HiddenWindowView.swift ================================================ import SwiftUI struct HiddenWindowView: View { @Environment(\.openSettings) private var openSettings var body: some View { Color.clear .frame(width: 20, height: 20) .onReceive(NotificationCenter.default.publisher(for: .codexbarOpenSettings)) { _ in Task { @MainActor in self.openSettings() } } .task { // Migrate keychain items to reduce permission prompts during development (runs off main thread) await Task.detached(priority: .userInitiated) { KeychainMigration.migrateIfNeeded() }.value } .onAppear { if let window = NSApp.windows.first(where: { $0.title == "CodexBarLifecycleKeepalive" }) { // Make the keepalive window truly invisible and non-interactive. window.styleMask = [.borderless] window.collectionBehavior = [.auxiliary, .ignoresCycle, .transient, .canJoinAllSpaces] window.isExcludedFromWindowsMenu = true window.level = .floating window.isOpaque = false window.alphaValue = 0 window.backgroundColor = .clear window.hasShadow = false window.ignoresMouseEvents = true window.canHide = false window.setContentSize(NSSize(width: 1, height: 1)) window.setFrameOrigin(NSPoint(x: -5000, y: -5000)) } } } } ================================================ FILE: Sources/CodexBar/HistoricalUsagePace.swift ================================================ import CodexBarCore import Foundation enum HistoricalUsageWindowKind: String, Codable { case secondary } enum HistoricalUsageRecordSource: String, Codable { case live case backfill } struct HistoricalUsageRecord: Codable { let v: Int let provider: UsageProvider let windowKind: HistoricalUsageWindowKind let source: HistoricalUsageRecordSource let accountKey: String? let sampledAt: Date let usedPercent: Double let resetsAt: Date let windowMinutes: Int init( v: Int, provider: UsageProvider, windowKind: HistoricalUsageWindowKind, source: HistoricalUsageRecordSource, accountKey: String?, sampledAt: Date, usedPercent: Double, resetsAt: Date, windowMinutes: Int) { self.v = v self.provider = provider self.windowKind = windowKind self.source = source self.accountKey = accountKey self.sampledAt = sampledAt self.usedPercent = usedPercent self.resetsAt = resetsAt self.windowMinutes = windowMinutes } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.v = try container.decodeIfPresent(Int.self, forKey: .v) ?? 1 self.provider = try container.decode(UsageProvider.self, forKey: .provider) self.windowKind = try container.decode(HistoricalUsageWindowKind.self, forKey: .windowKind) self.source = try container.decodeIfPresent(HistoricalUsageRecordSource.self, forKey: .source) ?? .live self.accountKey = try container.decodeIfPresent(String.self, forKey: .accountKey) self.sampledAt = try container.decode(Date.self, forKey: .sampledAt) self.usedPercent = try container.decode(Double.self, forKey: .usedPercent) self.resetsAt = try container.decode(Date.self, forKey: .resetsAt) self.windowMinutes = try container.decode(Int.self, forKey: .windowMinutes) } } struct HistoricalWeekProfile { let resetsAt: Date let windowMinutes: Int let curve: [Double] } struct CodexHistoricalDataset { static let gridPointCount = 169 let weeks: [HistoricalWeekProfile] } actor HistoricalUsageHistoryStore { private static let schemaVersion = 1 private static let writeInterval: TimeInterval = 30 * 60 private static let writeDeltaThreshold: Double = 1 private static let retentionDays: TimeInterval = 56 * 24 * 60 * 60 private static let minimumWeekSamples = 6 private static let boundaryCoverageWindow: TimeInterval = 24 * 60 * 60 private static let backfillWindowCapWeeks = 8 private static let backfillCalibrationMinimumUsedPercent = 1.0 private static let backfillCalibrationMinimumCredits = 0.001 private static let backfillSampleFractions: [Double] = (0...14).map { Double($0) / 14.0 } private static let coverageTolerance: TimeInterval = 16 * 60 * 60 private static let resetBucketSeconds: TimeInterval = 60 private let fileURL: URL private var records: [HistoricalUsageRecord] = [] private var loaded = false init(fileURL: URL? = nil) { self.fileURL = fileURL ?? HistoricalUsageHistoryStore.defaultFileURL() } func loadCodexDataset(accountKey: String?) -> CodexHistoricalDataset? { self.ensureLoaded() return self.buildDataset(accountKey: accountKey) } func recordCodexWeekly( window: RateWindow, sampledAt: Date = .init(), accountKey: String?) -> CodexHistoricalDataset? { guard let rawResetsAt = window.resetsAt else { return self.loadCodexDataset(accountKey: accountKey) } guard let windowMinutes = window.windowMinutes, windowMinutes > 0 else { return self.loadCodexDataset(accountKey: accountKey) } self.ensureLoaded() let resetsAt = Self.normalizeReset(rawResetsAt) let sample = HistoricalUsageRecord( v: Self.schemaVersion, provider: .codex, windowKind: .secondary, source: .live, accountKey: accountKey, sampledAt: sampledAt, usedPercent: Self.clamp(window.usedPercent, lower: 0, upper: 100), resetsAt: resetsAt, windowMinutes: windowMinutes) if !self.shouldAccept(sample) { return self.buildDataset(accountKey: accountKey) } self.records.append(sample) self.pruneOldRecords(now: sampledAt) self.records.sort { lhs, rhs in if lhs.sampledAt == rhs.sampledAt { if lhs.resetsAt == rhs.resetsAt { return lhs.usedPercent < rhs.usedPercent } return lhs.resetsAt < rhs.resetsAt } return lhs.sampledAt < rhs.sampledAt } self.persist() return self.buildDataset(accountKey: accountKey) } func backfillCodexWeeklyFromUsageBreakdown( _ breakdown: [OpenAIDashboardDailyBreakdown], referenceWindow: RateWindow, now: Date = .init(), accountKey: String?) -> CodexHistoricalDataset? { self.ensureLoaded() let existingDataset = self.buildDataset(accountKey: accountKey) guard let rawResetsAt = referenceWindow.resetsAt else { return existingDataset } guard let windowMinutes = referenceWindow.windowMinutes, windowMinutes > 0 else { return existingDataset } let resetsAt = Self.normalizeReset(rawResetsAt) let duration = TimeInterval(windowMinutes) * 60 guard duration > 0 else { return existingDataset } let windowStart = resetsAt.addingTimeInterval(-duration) let calibrationEnd = Self.clampDate(now, lower: windowStart, upper: resetsAt) let dayUsages = Self.parseDayUsages( from: breakdown, asOf: calibrationEnd, fillingFrom: windowStart) guard !dayUsages.isEmpty else { return existingDataset } guard let coverageStart = dayUsages.first?.start, let coverageEnd = dayUsages.last?.end else { return existingDataset } guard coverageStart <= windowStart.addingTimeInterval(Self.coverageTolerance) else { return existingDataset } guard coverageEnd >= calibrationEnd.addingTimeInterval(-Self.coverageTolerance) else { return existingDataset } let currentUsedPercent = Self.clamp(referenceWindow.usedPercent, lower: 0, upper: 100) guard currentUsedPercent >= Self.backfillCalibrationMinimumUsedPercent else { return existingDataset } let currentCredits = Self.creditsUsed( from: dayUsages, between: windowStart, and: calibrationEnd) guard currentCredits > Self.backfillCalibrationMinimumCredits else { return existingDataset } let estimatedCreditsAtLimit = currentCredits / (currentUsedPercent / 100) guard estimatedCreditsAtLimit.isFinite, estimatedCreditsAtLimit > Self.backfillCalibrationMinimumCredits else { return existingDataset } struct RecordKey: Hashable { let resetsAt: Date let sampledAt: Date let windowMinutes: Int let accountKey: String? } var synthesized: [HistoricalUsageRecord] = [] synthesized.reserveCapacity(Self.backfillWindowCapWeeks * Self.backfillSampleFractions.count) for weeksBack in 1...Self.backfillWindowCapWeeks { let reset = Self.normalizeReset(resetsAt.addingTimeInterval(-duration * Double(weeksBack))) let start = reset.addingTimeInterval(-duration) guard start >= coverageStart.addingTimeInterval(-Self.coverageTolerance), reset <= coverageEnd.addingTimeInterval(Self.coverageTolerance) else { continue } let existingForWeek = self.records.filter { $0.provider == .codex && $0.windowKind == .secondary && $0.windowMinutes == windowMinutes && $0.accountKey == accountKey && $0.resetsAt == reset } if Self.isCompleteWeek(samples: existingForWeek, windowStart: start, resetsAt: reset) { continue } var existingRecordKeys = Set(existingForWeek.map { RecordKey( resetsAt: $0.resetsAt, sampledAt: $0.sampledAt, windowMinutes: $0.windowMinutes, accountKey: $0.accountKey) }) let weekCredits = Self.creditsUsed(from: dayUsages, between: start, and: reset) guard weekCredits > Self.backfillCalibrationMinimumCredits else { continue } for fraction in Self.backfillSampleFractions { let sampledAt = start.addingTimeInterval(duration * fraction) let recordKey = RecordKey( resetsAt: reset, sampledAt: sampledAt, windowMinutes: windowMinutes, accountKey: accountKey) guard !existingRecordKeys.contains(recordKey) else { continue } let cumulativeCredits = Self.creditsUsed(from: dayUsages, between: start, and: sampledAt) let usedPercent = Self.clamp((cumulativeCredits / estimatedCreditsAtLimit) * 100, lower: 0, upper: 100) synthesized.append(HistoricalUsageRecord( v: Self.schemaVersion, provider: .codex, windowKind: .secondary, source: .backfill, accountKey: accountKey, sampledAt: sampledAt, usedPercent: usedPercent, resetsAt: reset, windowMinutes: windowMinutes)) existingRecordKeys.insert(recordKey) } } guard !synthesized.isEmpty else { return existingDataset } self.records.append(contentsOf: synthesized) self.pruneOldRecords(now: now) self.records.sort { lhs, rhs in if lhs.sampledAt == rhs.sampledAt { if lhs.resetsAt == rhs.resetsAt { return lhs.usedPercent < rhs.usedPercent } return lhs.resetsAt < rhs.resetsAt } return lhs.sampledAt < rhs.sampledAt } self.persist() return self.buildDataset(accountKey: accountKey) } private func shouldAccept(_ sample: HistoricalUsageRecord) -> Bool { guard let prior = self.records .last(where: { $0.provider == sample.provider && $0.windowKind == sample.windowKind && $0.accountKey == sample.accountKey && $0.windowMinutes == sample.windowMinutes }) else { return true } if prior.resetsAt != sample.resetsAt { return true } if sample.sampledAt.timeIntervalSince(prior.sampledAt) >= Self.writeInterval { return true } if abs(sample.usedPercent - prior.usedPercent) >= Self.writeDeltaThreshold { return true } return false } private func pruneOldRecords(now: Date) { let cutoff = now.addingTimeInterval(-Self.retentionDays) self.records.removeAll { $0.sampledAt < cutoff } } private func ensureLoaded() { guard !self.loaded else { return } self.loaded = true self.records = self.readRecordsFromDisk() self.pruneOldRecords(now: .init()) } private func readRecordsFromDisk() -> [HistoricalUsageRecord] { guard let data = try? Data(contentsOf: self.fileURL), !data.isEmpty else { return [] } guard let text = String(data: data, encoding: .utf8) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var decoded: [HistoricalUsageRecord] = [] decoded.reserveCapacity(text.count / 80) for rawLine in text.split(separator: "\n", omittingEmptySubsequences: true) { let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) guard !line.isEmpty, let lineData = line.data(using: .utf8) else { continue } guard var record = try? decoder.decode(HistoricalUsageRecord.self, from: lineData) else { continue } record = HistoricalUsageRecord( v: record.v, provider: record.provider, windowKind: record.windowKind, source: record.source, accountKey: record.accountKey?.isEmpty == false ? record.accountKey : nil, sampledAt: record.sampledAt, usedPercent: Self.clamp(record.usedPercent, lower: 0, upper: 100), resetsAt: Self.normalizeReset(record.resetsAt), windowMinutes: record.windowMinutes) decoded.append(record) } return decoded } private func persist() { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.sortedKeys] var lines: [String] = [] lines.reserveCapacity(self.records.count) for record in self.records { guard let data = try? encoder.encode(record), let line = String(data: data, encoding: .utf8) else { continue } lines.append(line) } let payload = (lines.joined(separator: "\n") + "\n").data(using: .utf8) ?? Data() let directory = self.fileURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try payload.write(to: self.fileURL, options: [.atomic]) } catch { // Best-effort cache file; ignore write failures. } } private func buildDataset(accountKey: String?) -> CodexHistoricalDataset? { struct WeekKey: Hashable { let resetsAt: Date let windowMinutes: Int } let scoped = self.records .filter { record in guard record.provider == .codex, record.windowKind == .secondary, record.windowMinutes > 0 else { return false } if let accountKey { return record.accountKey == accountKey } return record.accountKey == nil } if scoped.isEmpty { return nil } let grouped = Dictionary(grouping: scoped) { WeekKey(resetsAt: $0.resetsAt, windowMinutes: $0.windowMinutes) } var weeks: [HistoricalWeekProfile] = [] weeks.reserveCapacity(grouped.count) for (key, samples) in grouped { let duration = TimeInterval(key.windowMinutes) * 60 guard duration > 0 else { continue } let windowStart = key.resetsAt.addingTimeInterval(-duration) guard Self.isCompleteWeek(samples: samples, windowStart: windowStart, resetsAt: key.resetsAt) else { continue } guard let curve = Self.reconstructWeekCurve( samples: samples, windowStart: windowStart, windowDuration: duration, gridPointCount: CodexHistoricalDataset.gridPointCount) else { continue } weeks.append(HistoricalWeekProfile( resetsAt: key.resetsAt, windowMinutes: key.windowMinutes, curve: curve)) } weeks.sort { $0.resetsAt < $1.resetsAt } if weeks.isEmpty { return nil } return CodexHistoricalDataset(weeks: weeks) } private static func reconstructWeekCurve( samples: [HistoricalUsageRecord], windowStart: Date, windowDuration: TimeInterval, gridPointCount: Int) -> [Double]? { guard gridPointCount >= 2 else { return nil } var points = samples.map { sample -> (u: Double, value: Double) in let offset = sample.sampledAt.timeIntervalSince(windowStart) let u = Self.clamp(offset / windowDuration, lower: 0, upper: 1) return (u: u, value: Self.clamp(sample.usedPercent, lower: 0, upper: 100)) } points.sort { lhs, rhs in if lhs.u == rhs.u { return lhs.value < rhs.value } return lhs.u < rhs.u } guard !points.isEmpty else { return nil } // Enforce monotonicity on observed samples before interpolation. var monotonePoints: [(u: Double, value: Double)] = [] monotonePoints.reserveCapacity(points.count) var runningMax = 0.0 for point in points { runningMax = max(runningMax, point.value) monotonePoints.append((u: point.u, value: runningMax)) } // Anchor reconstructed curves to reset start and end-of-week plateau. let endValue = monotonePoints.last?.value ?? 0 monotonePoints.append((u: 0, value: 0)) monotonePoints.append((u: 1, value: endValue)) monotonePoints.sort { lhs, rhs in if lhs.u == rhs.u { return lhs.value < rhs.value } return lhs.u < rhs.u } runningMax = 0 for index in monotonePoints.indices { runningMax = max(runningMax, monotonePoints[index].value) monotonePoints[index].value = runningMax } var curve = Array(repeating: 0.0, count: gridPointCount) let first = monotonePoints[0] let last = monotonePoints[monotonePoints.count - 1] var upperIndex = 1 let denominator = Double(gridPointCount - 1) for index in 0..= last.u { curve[index] = last.value continue } while upperIndex < monotonePoints.count, monotonePoints[upperIndex].u < u { upperIndex += 1 } let hi = monotonePoints[min(upperIndex, monotonePoints.count - 1)] let lo = monotonePoints[max(0, upperIndex - 1)] if hi.u <= lo.u { curve[index] = max(lo.value, hi.value) continue } let ratio = Self.clamp((u - lo.u) / (hi.u - lo.u), lower: 0, upper: 1) curve[index] = lo.value + (hi.value - lo.value) * ratio } // Re-enforce monotonicity on reconstructed grid. var curveMax = 0.0 for index in curve.indices { curve[index] = Self.clamp(curve[index], lower: 0, upper: 100) curveMax = max(curveMax, curve[index]) curve[index] = curveMax } return curve } private static func isCompleteWeek(samples: [HistoricalUsageRecord], windowStart: Date, resetsAt: Date) -> Bool { guard samples.count >= self.minimumWeekSamples else { return false } let startBoundary = windowStart.addingTimeInterval(Self.boundaryCoverageWindow) let endBoundary = resetsAt.addingTimeInterval(-Self.boundaryCoverageWindow) let hasStartCoverage = samples.contains { sample in sample.sampledAt >= windowStart && sample.sampledAt <= startBoundary } let hasEndCoverage = samples.contains { sample in sample.sampledAt >= endBoundary && sample.sampledAt <= resetsAt } return hasStartCoverage && hasEndCoverage } private struct DayUsage { let start: Date let end: Date let creditsUsed: Double } private static func parseDayUsages( from breakdown: [OpenAIDashboardDailyBreakdown], asOf: Date, fillingFrom expectedCoverageStart: Date? = nil) -> [DayUsage] { var creditsByStart: [Date: Double] = [:] creditsByStart.reserveCapacity(breakdown.count) for day in breakdown { guard let dayStart = Self.dayStart(for: day.day) else { continue } creditsByStart[dayStart, default: 0] += max(0, day.totalCreditsUsed) } let calendar = Self.gregorianCalendar() var dayUsages: [DayUsage] = [] dayUsages.reserveCapacity(creditsByStart.count) for (dayStart, credits) in creditsByStart { guard let nominalEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { continue } let effectiveEnd: Date = if dayStart <= asOf, asOf < nominalEnd { asOf } else { nominalEnd } guard effectiveEnd > dayStart else { continue } dayUsages.append(DayUsage(start: dayStart, end: effectiveEnd, creditsUsed: credits)) } dayUsages.sort { lhs, rhs in lhs.start < rhs.start } return Self.fillMissingZeroUsageDays( in: dayUsages, through: asOf, fillingFrom: expectedCoverageStart) } private static func fillMissingZeroUsageDays( in dayUsages: [DayUsage], through asOf: Date, fillingFrom expectedCoverageStart: Date? = nil) -> [DayUsage] { guard let firstStart = dayUsages.first?.start else { return [] } let calendar = Self.gregorianCalendar() let fillStart: Date = if let expectedCoverageStart { min(firstStart, calendar.startOfDay(for: expectedCoverageStart)) } else { firstStart } let finalDayStart = calendar.startOfDay(for: asOf) guard fillStart <= finalDayStart else { return dayUsages } let creditsByStart = Dictionary(uniqueKeysWithValues: dayUsages.map { ($0.start, $0.creditsUsed) }) let daySpan = max(0, calendar.dateComponents([.day], from: fillStart, to: finalDayStart).day ?? 0) var filled: [DayUsage] = [] filled.reserveCapacity(daySpan + 1) var cursor = fillStart while cursor <= finalDayStart { guard let nominalEnd = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } let effectiveEnd: Date = if cursor <= asOf, asOf < nominalEnd { asOf } else { nominalEnd } guard effectiveEnd > cursor else { break } filled.append(DayUsage( start: cursor, end: effectiveEnd, creditsUsed: creditsByStart[cursor] ?? 0)) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = next } return filled } private static func dayStart(for key: String) -> Date? { let components = key.split(separator: "-", omittingEmptySubsequences: true) guard components.count == 3, let year = Int(components[0]), let month = Int(components[1]), let day = Int(components[2]) else { return nil } let calendar = Self.gregorianCalendar() var dateComponents = DateComponents() dateComponents.calendar = calendar dateComponents.timeZone = calendar.timeZone dateComponents.year = year dateComponents.month = month dateComponents.day = day dateComponents.hour = 0 dateComponents.minute = 0 dateComponents.second = 0 return dateComponents.date } private static func creditsUsed(from dayUsages: [DayUsage], between start: Date, and end: Date) -> Double { guard end > start else { return 0 } var total = 0.0 for day in dayUsages { if day.end <= start { continue } if day.start >= end { break } let overlapStart = max(day.start, start) let overlapEnd = min(day.end, end) guard overlapEnd > overlapStart else { continue } let dayDuration = day.end.timeIntervalSince(day.start) guard dayDuration > 0 else { continue } let overlap = overlapEnd.timeIntervalSince(overlapStart) total += day.creditsUsed * (overlap / dayDuration) } return max(0, total) } nonisolated static func defaultFileURL() -> URL { let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser return root .appendingPathComponent("CodexBar", isDirectory: true) .appendingPathComponent("usage-history.jsonl", isDirectory: false) } private nonisolated static func clamp(_ value: Double, lower: Double, upper: Double) -> Double { min(upper, max(lower, value)) } private nonisolated static func clampDate(_ value: Date, lower: Date, upper: Date) -> Date { min(upper, max(lower, value)) } private nonisolated static func normalizeReset(_ value: Date) -> Date { let bucket = Self.resetBucketSeconds guard bucket > 0 else { return value } let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket return Date(timeIntervalSinceReferenceDate: rounded) } private nonisolated static func gregorianCalendar() -> Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone.current return calendar } #if DEBUG nonisolated static func _dayStartForTesting(_ key: String) -> Date? { self.dayStart(for: key) } nonisolated static func _creditsUsedForTesting( breakdown: [OpenAIDashboardDailyBreakdown], asOf: Date, start: Date, end: Date) -> Double { let dayUsages = Self.parseDayUsages(from: breakdown, asOf: asOf) return Self.creditsUsed(from: dayUsages, between: start, and: end) } #endif } enum CodexHistoricalPaceEvaluator { static let minimumCompleteWeeksForHistorical = 3 static let minimumWeeksForRisk = 5 private static let recencyTauWeeks: Double = 3 private static let epsilon: Double = 1e-9 private static let resetBucketSeconds: TimeInterval = 60 static func evaluate(window: RateWindow, now: Date, dataset: CodexHistoricalDataset?) -> UsagePace? { guard let dataset else { return nil } guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? 10080 guard minutes > 0 else { return nil } let duration = TimeInterval(minutes) * 60 guard duration > 0 else { return nil } let timeUntilReset = resetsAt.timeIntervalSince(now) guard timeUntilReset > 0, timeUntilReset <= duration else { return nil } let normalizedResetsAt = Self.normalizeReset(resetsAt) let elapsed = Self.clamp(duration - timeUntilReset, lower: 0, upper: duration) let actual = Self.clamp(window.usedPercent, lower: 0, upper: 100) if elapsed == 0, actual > 0 { return nil } let uNow = Self.clamp(elapsed / duration, lower: 0, upper: 1) let scopedWeeks = dataset.weeks.filter { week in week.windowMinutes == minutes && week.resetsAt < normalizedResetsAt } guard scopedWeeks.count >= Self.minimumCompleteWeeksForHistorical else { return nil } let weightedWeeks = scopedWeeks.map { week in let ageWeeks = Self.clamp( normalizedResetsAt.timeIntervalSince(week.resetsAt) / duration, lower: 0, upper: Double.greatestFiniteMagnitude) let weight = exp(-ageWeeks / Self.recencyTauWeeks) return (week: week, weight: weight) } let totalWeight = weightedWeeks.reduce(0.0) { $0 + $1.weight } guard totalWeight > Self.epsilon else { return nil } let totalWeightSquared = weightedWeeks.reduce(0.0) { $0 + ($1.weight * $1.weight) } let nEff = totalWeightSquared > Self.epsilon ? (totalWeight * totalWeight) / totalWeightSquared : 0 let lambda = Self.clamp((nEff - 2) / 6, lower: 0, upper: 1) let gridCount = CodexHistoricalDataset.gridPointCount let denominator = Double(gridCount - 1) var expectedCurve = Array(repeating: 0.0, count: gridCount) for index in 0..= 100 - Self.epsilon if runOut { weightedRunOutMass += weight if let crossingU = Self.firstCrossing( after: uNow, curve: week.curve, shift: shift, actualAtNow: actual) { let etaSeconds = max(0, (crossingU - uNow) * duration) crossingCandidates.append((etaSeconds: etaSeconds, weight: weight)) } } } let smoothedProbability = Self.clamp( (weightedRunOutMass + 0.5) / (totalWeight + 1), lower: 0, upper: 1) let runOutProbability: Double? = scopedWeeks.count >= Self.minimumWeeksForRisk ? smoothedProbability : nil var willLastToReset = smoothedProbability < 0.5 var etaSeconds: TimeInterval? if !willLastToReset { let values = crossingCandidates.map(\.etaSeconds) let weights = crossingCandidates.map(\.weight) if values.isEmpty { willLastToReset = true } else { etaSeconds = max(0, Self.weightedMedian(values: values, weights: weights)) } } return UsagePace.historical( expectedUsedPercent: expectedNow, actualUsedPercent: actual, etaSeconds: etaSeconds, willLastToReset: willLastToReset, runOutProbability: runOutProbability) } private static func firstCrossing( after uNow: Double, curve: [Double], shift: Double, actualAtNow: Double) -> Double? { let gridCount = curve.count guard gridCount >= 2 else { return nil } let denominator = Double(gridCount - 1) var previousU = uNow var previousValue = actualAtNow let startIndex = min(gridCount - 1, max(1, Int(floor(uNow * denominator)) + 1)) for index in startIndex..= 100 - Self.epsilon { let delta = value - previousValue if abs(delta) <= Self.epsilon { return u } let ratio = Self.clamp((100 - previousValue) / delta, lower: 0, upper: 1) return Self.clamp(previousU + ratio * (u - previousU), lower: uNow, upper: 1) } previousU = u previousValue = value } return nil } private static func interpolate(curve: [Double], at u: Double) -> Double { guard !curve.isEmpty else { return 0 } if curve.count == 1 { return curve[0] } let clipped = Self.clamp(u, lower: 0, upper: 1) let scaled = clipped * Double(curve.count - 1) let lower = Int(floor(scaled)) let upper = min(curve.count - 1, lower + 1) if lower == upper { return curve[lower] } let ratio = scaled - Double(lower) return curve[lower] + ((curve[upper] - curve[lower]) * ratio) } private static func weightedMedian(values: [Double], weights: [Double]) -> Double { guard values.count == weights.count, !values.isEmpty else { return 0 } let pairs = zip(values, weights) .map { (value: $0, weight: max(0, $1)) } .sorted { lhs, rhs in lhs.value < rhs.value } let totalWeight = pairs.reduce(0.0) { $0 + $1.weight } if totalWeight <= Self.epsilon { let sortedValues = values.sorted() return sortedValues[sortedValues.count / 2] } let threshold = totalWeight / 2 var cumulative = 0.0 for pair in pairs { cumulative += pair.weight if cumulative >= threshold { return pair.value } } return pairs.last?.value ?? 0 } private static func clamp(_ value: Double, lower: Double, upper: Double) -> Double { min(upper, max(lower, value)) } private static func normalizeReset(_ value: Date) -> Date { let bucket = Self.resetBucketSeconds guard bucket > 0 else { return value } let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket return Date(timeIntervalSinceReferenceDate: rounded) } } ================================================ FILE: Sources/CodexBar/IconRenderer.swift ================================================ import AppKit import CodexBarCore // swiftlint:disable:next type_body_length enum IconRenderer { private static let creditsCap: Double = 1000 private static let baseSize = NSSize(width: 18, height: 18) // Render to an 18×18 pt template (36×36 px at 2×) to match the system menu bar size. private static let outputSize = NSSize(width: 18, height: 18) private static let outputScale: CGFloat = 2 private static let canvasPx = Int(outputSize.width * outputScale) private struct PixelGrid { let scale: CGFloat func pt(_ px: Int) -> CGFloat { CGFloat(px) / self.scale } func rect(x: Int, y: Int, w: Int, h: Int) -> CGRect { CGRect(x: self.pt(x), y: self.pt(y), width: self.pt(w), height: self.pt(h)) } func snapDelta(_ value: CGFloat) -> CGFloat { (value * self.scale).rounded() / self.scale } } private static let grid = PixelGrid(scale: outputScale) private struct IconCacheKey: Hashable { let primary: Int let weekly: Int let credits: Int let stale: Bool let style: Int let indicator: Int } private final class IconCacheStore: @unchecked Sendable { private var cache: [IconCacheKey: NSImage] = [:] private var order: [IconCacheKey] = [] private let lock = NSLock() func cachedIcon(for key: IconCacheKey) -> NSImage? { self.lock.lock() defer { self.lock.unlock() } guard let image = self.cache[key] else { return nil } if let idx = self.order.firstIndex(of: key) { self.order.remove(at: idx) self.order.append(key) } return image } func storeIcon(_ image: NSImage, for key: IconCacheKey, limit: Int) { self.lock.lock() defer { self.lock.unlock() } self.cache[key] = image self.order.removeAll { $0 == key } self.order.append(key) while self.order.count > limit { let oldest = self.order.removeFirst() self.cache.removeValue(forKey: oldest) } } } private static let iconCacheStore = IconCacheStore() private static let iconCacheLimit = 64 private static let morphBucketCount = 200 private static let morphCache = MorphCache(limit: 512) private final class MorphCache: @unchecked Sendable { private let cache = NSCache() init(limit: Int) { self.cache.countLimit = limit } func image(for key: NSNumber) -> NSImage? { self.cache.object(forKey: key) } func set(_ image: NSImage, for key: NSNumber) { self.cache.setObject(image, forKey: key) } } private struct RectPx: Hashable { let x: Int let y: Int let w: Int let h: Int var midXPx: Int { self.x + self.w / 2 } var midYPx: Int { self.y + self.h / 2 } func rect() -> CGRect { Self.grid.rect(x: self.x, y: self.y, w: self.w, h: self.h) } private static let grid = IconRenderer.grid } // swiftlint:disable function_body_length static func makeIcon( primaryRemaining: Double?, weeklyRemaining: Double?, creditsRemaining: Double?, stale: Bool, style: IconStyle, blink: CGFloat = 0, wiggle: CGFloat = 0, tilt: CGFloat = 0, statusIndicator: ProviderStatusIndicator = .none) -> NSImage { let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001 let render = { self.renderImage { // Keep monochrome template icons; Claude uses subtle shape cues only. let baseFill = NSColor.labelColor let trackFillAlpha: CGFloat = stale ? 0.18 : 0.28 let trackStrokeAlpha: CGFloat = stale ? 0.28 : 0.44 let fillColor = baseFill.withAlphaComponent(stale ? 0.55 : 1.0) let barWidthPx = 30 // 15 pt at 2×, uses the slot better without touching edges. let barXPx = (Self.canvasPx - barWidthPx) / 2 func drawBar( rectPx: RectPx, remaining: Double?, alpha: CGFloat = 1.0, addNotches: Bool = false, addFace: Bool = false, addGeminiTwist: Bool = false, addAntigravityTwist: Bool = false, addFactoryTwist: Bool = false, addWarpTwist: Bool = false, blink: CGFloat = 0, drawTrackFill: Bool = true, warpEyesFilled: Bool = false) { let rect = rectPx.rect() // Claude reads better as a blockier critter; Codex stays as a capsule. // Warp uses small corner radius for rounded rectangle (matching logo style) let cornerRadiusPx = addNotches ? 0 : (addWarpTwist ? 3 : rectPx.h / 2) let radius = Self.grid.pt(cornerRadiusPx) let trackPath = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) if drawTrackFill { baseFill.withAlphaComponent(trackFillAlpha * alpha).setFill() trackPath.fill() } // Crisp outline: stroke an inset path so the stroke stays within pixel bounds. let strokeWidthPx = 2 // 1 pt == 2 px at 2× let insetPx = strokeWidthPx / 2 let strokeRect = Self.grid.rect( x: rectPx.x + insetPx, y: rectPx.y + insetPx, w: max(0, rectPx.w - insetPx * 2), h: max(0, rectPx.h - insetPx * 2)) let strokePath = NSBezierPath( roundedRect: strokeRect, xRadius: Self.grid.pt(max(0, cornerRadiusPx - insetPx)), yRadius: Self.grid.pt(max(0, cornerRadiusPx - insetPx))) strokePath.lineWidth = CGFloat(strokeWidthPx) / Self.outputScale baseFill.withAlphaComponent(trackStrokeAlpha * alpha).setStroke() strokePath.stroke() // Fill: clip to the capsule and paint a left-to-right rect so the progress edge is straight. if let remaining { let clamped = max(0, min(remaining / 100, 1)) let fillWidthPx = max(0, min(rectPx.w, Int((CGFloat(rectPx.w) * CGFloat(clamped)).rounded()))) if fillWidthPx > 0 { NSGraphicsContext.current?.cgContext.saveGState() trackPath.addClip() fillColor.withAlphaComponent(alpha).setFill() NSBezierPath( rect: Self.grid.rect( x: rectPx.x, y: rectPx.y, w: fillWidthPx, h: rectPx.h)).fill() NSGraphicsContext.current?.cgContext.restoreGState() } } // Codex face: eye cutouts plus faint eyelids to give the prompt some personality. if addFace { let ctx = NSGraphicsContext.current?.cgContext let eyeSizePx = 4 let eyeOffsetPx = 7 let eyeCenterYPx = rectPx.y + rectPx.h / 2 let centerXPx = rectPx.midXPx ctx?.saveGState() ctx?.setShouldAntialias(false) ctx?.clear(Self.grid.rect( x: centerXPx - eyeOffsetPx - eyeSizePx / 2, y: eyeCenterYPx - eyeSizePx / 2, w: eyeSizePx, h: eyeSizePx)) ctx?.clear(Self.grid.rect( x: centerXPx + eyeOffsetPx - eyeSizePx / 2, y: eyeCenterYPx - eyeSizePx / 2, w: eyeSizePx, h: eyeSizePx)) ctx?.restoreGState() // Blink: refill eyes from the top down using the bar fill color. if blink > 0.001 { let clamped = max(0, min(blink, 1)) let blinkHeightPx = Int((CGFloat(eyeSizePx) * clamped).rounded()) fillColor.withAlphaComponent(alpha).setFill() let blinkRectLeft = Self.grid.rect( x: centerXPx - eyeOffsetPx - eyeSizePx / 2, y: eyeCenterYPx + eyeSizePx / 2 - blinkHeightPx, w: eyeSizePx, h: blinkHeightPx) let blinkRectRight = Self.grid.rect( x: centerXPx + eyeOffsetPx - eyeSizePx / 2, y: eyeCenterYPx + eyeSizePx / 2 - blinkHeightPx, w: eyeSizePx, h: blinkHeightPx) NSBezierPath(rect: blinkRectLeft).fill() NSBezierPath(rect: blinkRectRight).fill() } // Hat: a tiny cap hovering above the eyes to give the face more character. let hatWidthPx = 18 let hatHeightPx = 4 let hatRect = Self.grid.rect( x: centerXPx - hatWidthPx / 2, y: rectPx.y + rectPx.h - hatHeightPx, w: hatWidthPx, h: hatHeightPx) ctx?.saveGState() if abs(tilt) > 0.0001 { // Tilt only the hat; keep eyes pixel-crisp and axis-aligned. let faceCenter = CGPoint(x: Self.grid.pt(centerXPx), y: Self.grid.pt(eyeCenterYPx)) ctx?.translateBy(x: faceCenter.x, y: faceCenter.y) ctx?.rotate(by: tilt) ctx?.translateBy(x: -faceCenter.x, y: -faceCenter.y - abs(tilt) * 1.2) } fillColor.withAlphaComponent(alpha).setFill() NSBezierPath(rect: hatRect).fill() ctx?.restoreGState() } // Claude twist: blocky crab-style critter (arms + legs + vertical eyes). if addNotches { let ctx = NSGraphicsContext.current?.cgContext let wiggleOffset = Self.grid.snapDelta(wiggle * 0.6) let wigglePx = Int((wiggleOffset * Self.outputScale).rounded()) fillColor.withAlphaComponent(alpha).setFill() // Arms/claws: mid-height side protrusions. // Keep within the 18×18pt canvas: barX is 3px, so 3px arms reach the edge without clipping. let armWidthPx = 3 let armHeightPx = max(0, rectPx.h - 6) let armYPx = rectPx.y + 3 + wigglePx / 6 let leftArm = Self.grid.rect( x: rectPx.x - armWidthPx, y: armYPx, w: armWidthPx, h: armHeightPx) let rightArm = Self.grid.rect( x: rectPx.x + rectPx.w, y: armYPx, w: armWidthPx, h: armHeightPx) NSBezierPath(rect: leftArm).fill() NSBezierPath(rect: rightArm).fill() // Legs: 4 little pixels underneath, like a tiny crab. let legCount = 4 let legWidthPx = 2 let legHeightPx = 3 let legYPx = rectPx.y - legHeightPx + wigglePx / 6 let stepPx = max(1, rectPx.w / (legCount + 1)) for idx in 0.. 0.001 { let clamped = max(0, min(blink, 1)) let blinkHeightPx = Int((CGFloat(eyeHeightPx) * clamped).rounded()) fillColor.withAlphaComponent(alpha).setFill() let leftBlink = Self.grid.rect( x: rectPx.midXPx - eyeOffsetPx - eyeWidthPx / 2, y: eyeYPx + eyeHeightPx - blinkHeightPx, w: eyeWidthPx, h: blinkHeightPx) let rightBlink = Self.grid.rect( x: rectPx.midXPx + eyeOffsetPx - eyeWidthPx / 2, y: eyeYPx + eyeHeightPx - blinkHeightPx, w: eyeWidthPx, h: blinkHeightPx) NSBezierPath(rect: leftBlink).fill() NSBezierPath(rect: rightBlink).fill() } } // Gemini twist: sparkle-inspired design with prominent 4-pointed stars as eyes // and decorative points extending from the bar. if addGeminiTwist { let ctx = NSGraphicsContext.current?.cgContext let centerXPx = rectPx.midXPx let eyeCenterYPx = rectPx.y + rectPx.h / 2 ctx?.saveGState() ctx?.setShouldAntialias(true) // 4-pointed star cutouts (Gemini sparkle eyes) - BIGGER let starSizePx = 8 let eyeOffsetPx = 8 let sr = Self.grid.pt(starSizePx / 2) let innerR = sr * 0.25 func drawStarCutout(cx: CGFloat, cy: CGFloat) { let path = NSBezierPath() for i in 0..<8 { let angle = CGFloat(i) * .pi / 4 - .pi / 2 let radius = (i % 2 == 0) ? sr : innerR let px = cx + cos(angle) * radius let py = cy + sin(angle) * radius if i == 0 { path.move(to: NSPoint(x: px, y: py)) } else { path.line(to: NSPoint(x: px, y: py)) } } path.close() path.fill() } let ldCx = Self.grid.pt(centerXPx - eyeOffsetPx) let rdCx = Self.grid.pt(centerXPx + eyeOffsetPx) let yCy = Self.grid.pt(eyeCenterYPx) // Clear star shapes for eyes ctx?.setBlendMode(.clear) drawStarCutout(cx: ldCx, cy: yCy) drawStarCutout(cx: rdCx, cy: yCy) ctx?.setBlendMode(.normal) // Decorative sparkle points extending from bar (sized to stay within 36px canvas) fillColor.withAlphaComponent(alpha).setFill() let pointHeightPx = 4 let pointWidthPx = 4 // Top center point (like a crown/sparkle) let topPointPath = NSBezierPath() let topCx = Self.grid.pt(centerXPx) let topBaseY = Self.grid.pt(rectPx.y + rectPx.h) let topPeakY = Self.grid.pt(rectPx.y + rectPx.h + pointHeightPx) let halfW = Self.grid.pt(pointWidthPx / 2) topPointPath.move(to: NSPoint(x: topCx - halfW, y: topBaseY)) topPointPath.line(to: NSPoint(x: topCx, y: topPeakY)) topPointPath.line(to: NSPoint(x: topCx + halfW, y: topBaseY)) topPointPath.close() topPointPath.fill() // Bottom center point let bottomPointPath = NSBezierPath() let bottomBaseY = Self.grid.pt(rectPx.y) let bottomPeakY = Self.grid.pt(rectPx.y - pointHeightPx) bottomPointPath.move(to: NSPoint(x: topCx - halfW, y: bottomBaseY)) bottomPointPath.line(to: NSPoint(x: topCx, y: bottomPeakY)) bottomPointPath.line(to: NSPoint(x: topCx + halfW, y: bottomBaseY)) bottomPointPath.close() bottomPointPath.fill() // Side points (max 3px to stay within canvas edge) let sidePointH = 3 let sidePointW = 3 let sideHalfW = Self.grid.pt(sidePointW / 2) let barMidY = Self.grid.pt(eyeCenterYPx) // Left side point let leftSidePath = NSBezierPath() let leftBaseX = Self.grid.pt(rectPx.x) let leftPeakX = Self.grid.pt(rectPx.x - sidePointH) leftSidePath.move(to: NSPoint(x: leftBaseX, y: barMidY - sideHalfW)) leftSidePath.line(to: NSPoint(x: leftPeakX, y: barMidY)) leftSidePath.line(to: NSPoint(x: leftBaseX, y: barMidY + sideHalfW)) leftSidePath.close() leftSidePath.fill() // Right side point let rightSidePath = NSBezierPath() let rightBaseX = Self.grid.pt(rectPx.x + rectPx.w) let rightPeakX = Self.grid.pt(rectPx.x + rectPx.w + sidePointH) rightSidePath.move(to: NSPoint(x: rightBaseX, y: barMidY - sideHalfW)) rightSidePath.line(to: NSPoint(x: rightPeakX, y: barMidY)) rightSidePath.line(to: NSPoint(x: rightBaseX, y: barMidY + sideHalfW)) rightSidePath.close() rightSidePath.fill() ctx?.restoreGState() // Blink: fill star eyes if blink > 0.001 { let clamped = max(0, min(blink, 1)) fillColor.withAlphaComponent(alpha).setFill() let blinkR = sr * clamped let blinkInnerR = blinkR * 0.25 func drawBlinkStar(cx: CGFloat, cy: CGFloat) { let path = NSBezierPath() for i in 0..<8 { let angle = CGFloat(i) * .pi / 4 - .pi / 2 let radius = (i % 2 == 0) ? blinkR : blinkInnerR let px = cx + cos(angle) * radius let py = cy + sin(angle) * radius if i == 0 { path.move(to: NSPoint(x: px, y: py)) } else { path.line(to: NSPoint(x: px, y: py)) } } path.close() path.fill() } drawBlinkStar(cx: ldCx, cy: yCy) drawBlinkStar(cx: rdCx, cy: yCy) } } if addAntigravityTwist { let dotSizePx = 3 let dotOffsetXPx = rectPx.x + rectPx.w + 2 let dotOffsetYPx = rectPx.y + rectPx.h - 2 fillColor.withAlphaComponent(alpha).setFill() let dotRect = Self.grid.rect( x: dotOffsetXPx - dotSizePx / 2, y: dotOffsetYPx - dotSizePx / 2, w: dotSizePx, h: dotSizePx) NSBezierPath(ovalIn: dotRect).fill() } // Factory twist: 8-pointed asterisk/gear-like eyes with cog teeth accents if addFactoryTwist { let ctx = NSGraphicsContext.current?.cgContext let centerXPx = rectPx.midXPx let eyeCenterYPx = rectPx.y + rectPx.h / 2 ctx?.saveGState() ctx?.setShouldAntialias(true) // 8-pointed asterisk cutouts (Factory gear-like eyes) let starSizePx = 7 let eyeOffsetPx = 8 let sr = Self.grid.pt(starSizePx / 2) let innerR = sr * 0.3 func drawAsteriskCutout(cx: CGFloat, cy: CGFloat) { let path = NSBezierPath() // 8 points for the asterisk for i in 0..<16 { let angle = CGFloat(i) * .pi / 8 - .pi / 2 let radius = (i % 2 == 0) ? sr : innerR let px = cx + cos(angle) * radius let py = cy + sin(angle) * radius if i == 0 { path.move(to: NSPoint(x: px, y: py)) } else { path.line(to: NSPoint(x: px, y: py)) } } path.close() path.fill() } let ldCx = Self.grid.pt(centerXPx - eyeOffsetPx) let rdCx = Self.grid.pt(centerXPx + eyeOffsetPx) let yCy = Self.grid.pt(eyeCenterYPx) // Clear asterisk shapes for eyes ctx?.setBlendMode(.clear) drawAsteriskCutout(cx: ldCx, cy: yCy) drawAsteriskCutout(cx: rdCx, cy: yCy) ctx?.setBlendMode(.normal) // Small gear teeth on top and bottom edges fillColor.withAlphaComponent(alpha).setFill() let toothWidthPx = 3 let toothHeightPx = 2 // Top teeth (2 small rectangles) let topY = Self.grid.pt(rectPx.y + rectPx.h) let tooth1X = Self.grid.pt(centerXPx - 5 - toothWidthPx / 2) let tooth2X = Self.grid.pt(centerXPx + 5 - toothWidthPx / 2) NSBezierPath(rect: CGRect( x: tooth1X, y: topY, width: Self.grid.pt(toothWidthPx), height: Self.grid.pt(toothHeightPx))).fill() NSBezierPath(rect: CGRect( x: tooth2X, y: topY, width: Self.grid.pt(toothWidthPx), height: Self.grid.pt(toothHeightPx))).fill() // Bottom teeth let bottomY = Self.grid.pt(rectPx.y - toothHeightPx) NSBezierPath(rect: CGRect( x: tooth1X, y: bottomY, width: Self.grid.pt(toothWidthPx), height: Self.grid.pt(toothHeightPx))).fill() NSBezierPath(rect: CGRect( x: tooth2X, y: bottomY, width: Self.grid.pt(toothWidthPx), height: Self.grid.pt(toothHeightPx))).fill() ctx?.restoreGState() // Blink: fill asterisk eyes if blink > 0.001 { let clamped = max(0, min(blink, 1)) fillColor.withAlphaComponent(alpha).setFill() let blinkR = sr * clamped let blinkInnerR = blinkR * 0.3 func drawBlinkAsterisk(cx: CGFloat, cy: CGFloat) { let path = NSBezierPath() for i in 0..<16 { let angle = CGFloat(i) * .pi / 8 - .pi / 2 let radius = (i % 2 == 0) ? blinkR : blinkInnerR let px = cx + cos(angle) * radius let py = cy + sin(angle) * radius if i == 0 { path.move(to: NSPoint(x: px, y: py)) } else { path.line(to: NSPoint(x: px, y: py)) } } path.close() path.fill() } drawBlinkAsterisk(cx: ldCx, cy: yCy) drawBlinkAsterisk(cx: rdCx, cy: yCy) } } // Warp twist: "Warp" style face with tilted-eye cutouts. if addWarpTwist { let ctx = NSGraphicsContext.current?.cgContext let centerXPx = rectPx.midXPx let eyeCenterYPx = rectPx.y + rectPx.h / 2 ctx?.saveGState() ctx?.setShouldAntialias(true) // Smooth edges for tilted ellipse eyes // 1. Draw Eyes (Tilted ellipse cutouts - "fox eye" / "cat eye" style) // Keep sizes in integer pixels so grid conversion stays exact. let eyeWidthPx = 5 let eyeHeightPx = 8 let eyeOffsetPx = 7 let eyeTiltAngle: CGFloat = .pi / 3 // 60 degrees tilt let leftEyeCx = Self.grid.pt(centerXPx) - Self.grid.pt(eyeOffsetPx) let rightEyeCx = Self.grid.pt(centerXPx) + Self.grid.pt(eyeOffsetPx) let eyeCy = Self.grid.pt(eyeCenterYPx) let eyeW = Self.grid.pt(eyeWidthPx) let eyeH = Self.grid.pt(eyeHeightPx) /// Draw a tilted ellipse eye at the given center. func drawTiltedEyeCutout(cx: CGFloat, cy: CGFloat, tiltAngle: CGFloat) { guard let ctx else { return } let eyeRect = CGRect(x: -eyeW / 2, y: -eyeH / 2, width: eyeW, height: eyeH) // Use CGContext transforms instead of AffineTransform-on-path so the rotation origin // is unambiguous and the current blend mode is consistently respected. ctx.saveGState() ctx.translateBy(x: cx, y: cy) ctx.rotate(by: tiltAngle) ctx.addEllipse(in: eyeRect) ctx.fillPath() ctx.restoreGState() } if warpEyesFilled { fillColor.withAlphaComponent(alpha).setFill() drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle) drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle) } else { // Clear eyes using blend mode ctx?.setBlendMode(.clear) drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle) drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle) ctx?.setBlendMode(.normal) } ctx?.restoreGState() // Restore graphics state } } let effectiveWeeklyRemaining: Double? = { if style == .warp, let weeklyRemaining, weeklyRemaining <= 0 { return nil } return weeklyRemaining }() let topValue = primaryRemaining let bottomValue = effectiveWeeklyRemaining let creditsRatio = creditsRemaining.map { min($0 / Self.creditsCap * 100, 100) } let hasWeekly = (bottomValue != nil) let weeklyAvailable = hasWeekly && (bottomValue ?? 0) > 0 let creditsAlpha: CGFloat = 1.0 let topRectPx = RectPx(x: barXPx, y: 19, w: barWidthPx, h: 12) let bottomRectPx = RectPx(x: barXPx, y: 5, w: barWidthPx, h: 8) let creditsRectPx = RectPx(x: barXPx, y: 14, w: barWidthPx, h: 16) let creditsBottomRectPx = RectPx(x: barXPx, y: 4, w: barWidthPx, h: 6) // Warp special case: when no bonus or bonus exhausted, show "top monthly, bottom dimmed" let warpNoBonus = style == .warp && !weeklyAvailable if weeklyAvailable { // Normal: top=primary, bottom=secondary (bonus/weekly). drawBar( rectPx: topRectPx, remaining: topValue, addNotches: style == .claude, addFace: style == .codex, addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) } else if !hasWeekly || warpNoBonus { if style == .warp { // Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track drawBar( rectPx: topRectPx, remaining: topValue, addWarpTwist: true, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) } else { // Weekly missing (e.g. Claude enterprise): keep normal layout but // dim the bottom track to indicate N/A. if topValue == nil, let ratio = creditsRatio { // Credits-only: show credits prominently (e.g. credits loaded before usage). drawBar( rectPx: creditsRectPx, remaining: ratio, alpha: creditsAlpha, addNotches: style == .claude, addFace: style == .codex, addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) } else { drawBar( rectPx: topRectPx, remaining: topValue, addNotches: style == .claude, addFace: style == .codex, addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) } } } else { // Weekly exhausted/missing: show credits on top (thicker), weekly (likely 0) on bottom. if let ratio = creditsRatio { drawBar( rectPx: creditsRectPx, remaining: ratio, alpha: creditsAlpha, addNotches: style == .claude, addFace: style == .codex, addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) } else { // No credits available; fall back to 5h if present. drawBar( rectPx: topRectPx, remaining: topValue, addNotches: style == .claude, addFace: style == .codex, addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, addWarpTwist: style == .warp, blink: blink) } drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue) } Self.drawStatusOverlay(indicator: statusIndicator) } } if shouldCache { let key = IconCacheKey( primary: self.quantizedPercent(primaryRemaining), weekly: self.quantizedPercent(weeklyRemaining), credits: self.quantizedCredits(creditsRemaining), stale: stale, style: self.styleKey(style), indicator: self.indicatorKey(statusIndicator)) if let cached = self.cachedIcon(for: key) { return cached } let image = render() self.storeIcon(image, for: key) return image } return render() } // swiftlint:enable function_body_length /// Morph helper: unbraids a simplified knot into our bar icon. static func makeMorphIcon(progress: Double, style: IconStyle) -> NSImage { let clamped = max(0, min(progress, 1)) let key = self.morphCacheKey(progress: clamped, style: style) if let cached = self.morphCache.image(for: key) { return cached } let image = self.renderImage { self.drawUnbraidMorph(t: clamped, style: style) } self.morphCache.set(image, for: key) return image } private static func quantizedPercent(_ value: Double?) -> Int { guard let value else { return -1 } return Int((value * 10).rounded()) } private static func quantizedCredits(_ value: Double?) -> Int { guard let value else { return -1 } let clamped = max(0, min(value, self.creditsCap)) return Int((clamped * 10).rounded()) } private static let styleKeyLookup: [IconStyle: Int] = { var lookup: [IconStyle: Int] = [:] for (index, style) in IconStyle.allCases.enumerated() { lookup[style] = index } return lookup }() private static func styleKey(_ style: IconStyle) -> Int { self.styleKeyLookup[style] ?? 0 } private static func indicatorKey(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 case .minor: 1 case .major: 2 case .critical: 3 case .maintenance: 4 case .unknown: 5 } } private static func morphCacheKey(progress: Double, style: IconStyle) -> NSNumber { let bucket = Int((progress * Double(self.morphBucketCount)).rounded()) let key = self.styleKey(style) * 1000 + bucket return NSNumber(value: key) } private static func cachedIcon(for key: IconCacheKey) -> NSImage? { self.iconCacheStore.cachedIcon(for: key) } private static func storeIcon(_ image: NSImage, for key: IconCacheKey) { self.iconCacheStore.storeIcon(image, for: key, limit: self.iconCacheLimit) } private static func drawUnbraidMorph(t: Double, style: IconStyle) { let t = CGFloat(max(0, min(t, 1))) let size = Self.baseSize let center = CGPoint(x: size.width / 2, y: size.height / 2) let baseColor = NSColor.labelColor struct Segment { let startCenter: CGPoint let endCenter: CGPoint let startAngle: CGFloat let endAngle: CGFloat let startLength: CGFloat let endLength: CGFloat let startThickness: CGFloat let endThickness: CGFloat let fadeOut: Bool } let segments: [Segment] = [ // Upper ribbon -> top bar .init( startCenter: center.offset(dx: 0, dy: 2), endCenter: CGPoint(x: center.x, y: 9.0), startAngle: -30, endAngle: 0, startLength: 16, endLength: 14, startThickness: 3.4, endThickness: 3.0, fadeOut: false), // Lower ribbon -> bottom bar .init( startCenter: center.offset(dx: 0, dy: -2), endCenter: CGPoint(x: center.x, y: 4.0), startAngle: 210, endAngle: 0, startLength: 16, endLength: 12, startThickness: 3.4, endThickness: 2.4, fadeOut: false), // Side ribbon fades away .init( startCenter: center, endCenter: center.offset(dx: 0, dy: 6), startAngle: 90, endAngle: 0, startLength: 16, endLength: 8, startThickness: 3.4, endThickness: 1.8, fadeOut: true), ] for seg in segments { let p = seg.fadeOut ? t * 1.1 : t let c = seg.startCenter.lerp(to: seg.endCenter, p: p) let angle = seg.startAngle.lerp(to: seg.endAngle, p: p) let length = seg.startLength.lerp(to: seg.endLength, p: p) let thickness = seg.startThickness.lerp(to: seg.endThickness, p: p) let alpha = seg.fadeOut ? (1 - p) : 1 self.drawRoundedRibbon( center: c, length: length, thickness: thickness, angle: angle, color: baseColor.withAlphaComponent(alpha)) } // Cross-fade in bar fill emphasis near the end of the morph. if t > 0.55 { let barT = (t - 0.55) / 0.45 let bars = self.makeIcon( primaryRemaining: 100, weeklyRemaining: 100, creditsRemaining: nil, stale: false, style: style) bars.draw(in: CGRect(origin: .zero, size: size), from: .zero, operation: .sourceOver, fraction: barT) } } private static func drawRoundedRibbon( center: CGPoint, length: CGFloat, thickness: CGFloat, angle: CGFloat, color: NSColor) { var transform = AffineTransform.identity transform.translate(x: center.x, y: center.y) transform.rotate(byDegrees: angle) transform.translate(x: -center.x, y: -center.y) let rect = CGRect( x: center.x - length / 2, y: center.y - thickness / 2, width: length, height: thickness) let path = NSBezierPath(roundedRect: rect, xRadius: thickness / 2, yRadius: thickness / 2) path.transform(using: transform) color.setFill() path.fill() } private static func drawStatusOverlay(indicator: ProviderStatusIndicator) { guard indicator.hasIssue else { return } let color = NSColor.labelColor switch indicator { case .minor, .maintenance: let size: CGFloat = 4 let rect = Self.snapRect( x: Self.baseSize.width - size - 2, y: 2, width: size, height: size) let path = NSBezierPath(ovalIn: rect) color.setFill() path.fill() case .major, .critical, .unknown: let lineRect = Self.snapRect( x: Self.baseSize.width - 6, y: 4, width: 2.0, height: 6) let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1) color.setFill() linePath.fill() let dotRect = Self.snapRect( x: Self.baseSize.width - 6, y: 2, width: 2.0, height: 2.0) NSBezierPath(ovalIn: dotRect).fill() case .none: break } } private static func withScaledContext(_ draw: () -> Void) { guard let ctx = NSGraphicsContext.current?.cgContext else { draw() return } ctx.saveGState() ctx.setShouldAntialias(true) ctx.interpolationQuality = .none draw() ctx.restoreGState() } private static func snap(_ value: CGFloat) -> CGFloat { (value * self.outputScale).rounded() / self.outputScale } private static func snapRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { CGRect(x: self.snap(x), y: self.snap(y), width: self.snap(width), height: self.snap(height)) } private static func renderImage(_ draw: () -> Void) -> NSImage { let image = NSImage(size: Self.outputSize) if let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: Int(Self.outputSize.width * Self.outputScale), pixelsHigh: Int(Self.outputSize.height * Self.outputScale), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) { rep.size = Self.outputSize // points image.addRepresentation(rep) NSGraphicsContext.saveGraphicsState() if let ctx = NSGraphicsContext(bitmapImageRep: rep) { NSGraphicsContext.current = ctx Self.withScaledContext(draw) } NSGraphicsContext.restoreGraphicsState() } else { // Fallback to legacy focus if the bitmap rep fails for any reason. image.lockFocus() Self.withScaledContext(draw) image.unlockFocus() } image.isTemplate = true return image } } extension CGPoint { fileprivate func lerp(to other: CGPoint, p: CGFloat) -> CGPoint { CGPoint(x: self.x + (other.x - self.x) * p, y: self.y + (other.y - self.y) * p) } fileprivate func offset(dx: CGFloat, dy: CGFloat) -> CGPoint { CGPoint(x: self.x + dx, y: self.y + dy) } } extension CGFloat { fileprivate func lerp(to other: CGFloat, p: CGFloat) -> CGFloat { self + (other - self) * p } } ================================================ FILE: Sources/CodexBar/IconView.swift ================================================ import CodexBarCore import SwiftUI @MainActor struct IconView: View { let snapshot: UsageSnapshot? let creditsRemaining: Double? let isStale: Bool let showLoadingAnimation: Bool let style: IconStyle @State private var phase: CGFloat = 0 @State private var displayLink = DisplayLinkDriver() @State private var pattern: LoadingPattern = .knightRider @State private var debugCycle = false @State private var cycleIndex = 0 @State private var cycleCounter = 0 private let loadingFPS: Double = 12 // Advance to next pattern every N ticks when debug cycling. private let cycleIntervalTicks = 20 private let patterns = LoadingPattern.allCases private var isLoading: Bool { self.showLoadingAnimation && self.snapshot == nil } var body: some View { Group { if let snapshot { Image(nsImage: IconRenderer.makeIcon( primaryRemaining: snapshot.primary?.remainingPercent, weeklyRemaining: snapshot.secondary?.remainingPercent, creditsRemaining: self.creditsRemaining, stale: self.isStale, style: self.style)) .renderingMode(.original) .interpolation(.none) .frame(width: 20, height: 18, alignment: .center) .padding(.horizontal, 2) } else if self.showLoadingAnimation { // Loading: animate bars with the current pattern until data arrives. Image(nsImage: self.loadingImage) .renderingMode(.original) .interpolation(.none) .frame(width: 20, height: 18, alignment: .center) .padding(.horizontal, 2) .onChange(of: self.displayLink.tick) { _, _ in self.phase += 0.09 // half-speed animation if self.debugCycle { self.cycleCounter += 1 if self.cycleCounter >= self.cycleIntervalTicks { self.cycleCounter = 0 self.cycleIndex = (self.cycleIndex + 1) % self.patterns.count self.pattern = self.patterns[self.cycleIndex] } } } } else { // No animation when usage/account is unavailable; show empty tracks. Image(nsImage: IconRenderer.makeIcon( primaryRemaining: nil, weeklyRemaining: nil, creditsRemaining: self.creditsRemaining, stale: self.isStale, style: self.style)) .renderingMode(.original) .interpolation(.none) .frame(width: 20, height: 18, alignment: .center) .padding(.horizontal, 2) } } .onChange(of: self.isLoading, initial: true) { _, isLoading in if isLoading { self.displayLink.start(fps: self.loadingFPS) if !self.debugCycle { self.pattern = self.patterns.randomElement() ?? .knightRider } } else { self.displayLink.stop() self.debugCycle = false self.phase = 0 } } .onDisappear { self.displayLink.stop() } .onReceive(NotificationCenter.default.publisher(for: .codexbarDebugReplayAllAnimations)) { notification in if let raw = notification.userInfo?["pattern"] as? String, let selected = LoadingPattern(rawValue: raw) { self.debugCycle = false self.pattern = selected self.cycleIndex = self.patterns.firstIndex(of: selected) ?? 0 } else { self.debugCycle = true self.cycleIndex = 0 self.pattern = self.patterns.first ?? .knightRider } self.cycleCounter = 0 self.phase = 0 } } private var loadingPrimary: Double { self.pattern.value(phase: Double(self.phase)) } private var loadingSecondary: Double { self.pattern.value(phase: Double(self.phase + self.pattern.secondaryOffset)) } private var loadingImage: NSImage { if self.pattern == .unbraid { let progress = self.loadingPrimary / 100 return IconRenderer.makeMorphIcon(progress: progress, style: self.style) } else { return IconRenderer.makeIcon( primaryRemaining: self.loadingPrimary, weeklyRemaining: self.loadingSecondary, creditsRemaining: nil, stale: false, style: self.style) } } } ================================================ FILE: Sources/CodexBar/InstallOrigin.swift ================================================ import Foundation enum InstallOrigin { static func isHomebrewCask(appBundleURL: URL) -> Bool { let resolved = appBundleURL.resolvingSymlinksInPath() let path = resolved.path return path.contains("/Caskroom/") || path.contains("/Homebrew/Caskroom/") } } ================================================ FILE: Sources/CodexBar/KeyboardShortcuts+Names.swift ================================================ import KeyboardShortcuts @MainActor extension KeyboardShortcuts.Name { static let openMenu = Self("openMenu") } ================================================ FILE: Sources/CodexBar/KeychainMigration.swift ================================================ import CodexBarCore import Foundation import Security /// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly /// to prevent permission prompts on every rebuild during development. enum KeychainMigration { private static let log = CodexBarLog.logger(LogCategories.keychainMigration) private static let migrationKey = "KeychainMigrationV1Completed" struct MigrationItem: Hashable { let service: String let account: String? var label: String { let accountLabel = self.account ?? "" return "\(self.service):\(accountLabel)" } } static let itemsToMigrate: [MigrationItem] = [ MigrationItem(service: "com.steipete.CodexBar", account: "codex-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "claude-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "cursor-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "factory-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "minimax-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "minimax-api-token"), MigrationItem(service: "com.steipete.CodexBar", account: "augment-cookie"), MigrationItem(service: "com.steipete.CodexBar", account: "copilot-api-token"), MigrationItem(service: "com.steipete.CodexBar", account: "zai-api-token"), MigrationItem(service: "com.steipete.CodexBar", account: "synthetic-api-key"), ] /// Run migration once per installation static func migrateIfNeeded() { guard !KeychainAccessGate.isDisabled else { self.log.info("Keychain access disabled; skipping migration") return } if !UserDefaults.standard.bool(forKey: self.migrationKey) { self.log.info("Starting keychain migration to reduce permission prompts") var migratedCount = 0 var errorCount = 0 for item in self.itemsToMigrate { do { if try self.migrateItem(item) { migratedCount += 1 } } catch { errorCount += 1 self.log.error("Failed to migrate \(item.label): \(String(describing: error))") } } self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors") UserDefaults.standard.set(true, forKey: self.migrationKey) if migratedCount > 0 { self.log.info("✅ Future rebuilds will not prompt for keychain access") } } else { self.log.debug("Keychain migration already completed, skipping") } } /// Migrate a single keychain item to the new accessibility level /// Returns true if item was migrated, false if item didn't exist private static func migrateItem(_ item: MigrationItem) throws -> Bool { // First, try to read the existing item var result: CFTypeRef? var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: item.service, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, kSecReturnAttributes as String: true, ] if let account = item.account { query[kSecAttrAccount as String] = account } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Item doesn't exist, nothing to migrate return false } guard status == errSecSuccess else { throw KeychainMigrationError.readFailed(status) } guard let rawItem = result as? [String: Any], let data = rawItem[kSecValueData as String] as? Data, let accessible = rawItem[kSecAttrAccessible as String] as? String else { throw KeychainMigrationError.invalidItemFormat } // Check if already using the correct accessibility if accessible == (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) { self.log.debug("\(item.label) already using correct accessibility") return false } // Delete the old item var deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: item.service, ] if let account = item.account { deleteQuery[kSecAttrAccount as String] = account } let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) guard deleteStatus == errSecSuccess else { throw KeychainMigrationError.deleteFailed(deleteStatus) } // Add it back with the new accessibility var addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: item.service, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] if let account = item.account { addQuery[kSecAttrAccount as String] = account } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { throw KeychainMigrationError.addFailed(addStatus) } self.log.info("Migrated \(item.label) to new accessibility level") return true } /// Reset migration flag (for testing) static func resetMigrationFlag() { UserDefaults.standard.removeObject(forKey: self.migrationKey) } } enum KeychainMigrationError: Error { case readFailed(OSStatus) case deleteFailed(OSStatus) case addFailed(OSStatus) case invalidItemFormat } ================================================ FILE: Sources/CodexBar/KeychainPromptCoordinator.swift ================================================ import AppKit import CodexBarCore import SweetCookieKit enum KeychainPromptCoordinator { private static let promptLock = NSLock() private static let log = CodexBarLog.logger(LogCategories.keychainPrompt) static func install() { KeychainPromptHandler.handler = { context in self.presentKeychainPrompt(context) } BrowserCookieKeychainPromptHandler.handler = { context in self.presentBrowserCookiePrompt(context) } } private static func presentKeychainPrompt(_ context: KeychainPromptContext) { let (title, message) = self.keychainCopy(for: context) self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"]) self.presentAlert(title: title, message: message) } private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) { let title = "Keychain Access Required" let message = [ "CodexBar will ask macOS Keychain for “\(context.label)” so it can decrypt browser cookies", "and authenticate your account. Click OK to continue.", ].joined(separator: " ") self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label]) self.presentAlert(title: title, message: message) } private static func keychainCopy(for context: KeychainPromptContext) -> (title: String, message: String) { let title = "Keychain Access Required" switch context.kind { case .claudeOAuth: return (title, [ "CodexBar will ask macOS Keychain for the Claude Code OAuth token", "so it can fetch your Claude usage. Click OK to continue.", ].joined(separator: " ")) case .codexCookie: return (title, [ "CodexBar will ask macOS Keychain for your OpenAI cookie header", "so it can fetch Codex dashboard extras. Click OK to continue.", ].joined(separator: " ")) case .claudeCookie: return (title, [ "CodexBar will ask macOS Keychain for your Claude cookie header", "so it can fetch Claude web usage. Click OK to continue.", ].joined(separator: " ")) case .cursorCookie: return (title, [ "CodexBar will ask macOS Keychain for your Cursor cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .opencodeCookie: return (title, [ "CodexBar will ask macOS Keychain for your OpenCode cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .factoryCookie: return (title, [ "CodexBar will ask macOS Keychain for your Factory cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .zaiToken: return (title, [ "CodexBar will ask macOS Keychain for your z.ai API token", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .syntheticToken: return (title, [ "CodexBar will ask macOS Keychain for your Synthetic API key", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .copilotToken: return (title, [ "CodexBar will ask macOS Keychain for your GitHub Copilot token", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .kimiToken: return (title, [ "CodexBar will ask macOS Keychain for your Kimi auth token", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .kimiK2Token: return (title, [ "CodexBar will ask macOS Keychain for your Kimi K2 API key", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .minimaxCookie: return (title, [ "CodexBar will ask macOS Keychain for your MiniMax cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .minimaxToken: return (title, [ "CodexBar will ask macOS Keychain for your MiniMax API token", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .augmentCookie: return (title, [ "CodexBar will ask macOS Keychain for your Augment cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) case .ampCookie: return (title, [ "CodexBar will ask macOS Keychain for your Amp cookie header", "so it can fetch usage. Click OK to continue.", ].joined(separator: " ")) } } private static func presentAlert(title: String, message: String) { self.promptLock.lock() defer { self.promptLock.unlock() } if Thread.isMainThread { MainActor.assumeIsolated { self.showAlert(title: title, message: message) } return } DispatchQueue.main.sync { MainActor.assumeIsolated { self.showAlert(title: title, message: message) } } } @MainActor private static func showAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.addButton(withTitle: "OK") _ = alert.runModal() } } ================================================ FILE: Sources/CodexBar/KimiK2TokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol KimiK2TokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum KimiK2TokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainKimiK2TokenStore: KimiK2TokenStoring { private static let log = CodexBarLog.logger(LogCategories.kimiK2TokenStore) private let service = "com.steipete.CodexBar" private let account = "kimi-k2-api-token" func loadToken() throws -> String? { var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .kimiK2Token, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw KimiK2TokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw KimiK2TokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let token, !token.isEmpty { return token } return nil } func storeToken(_ token: String?) throws { let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw KimiK2TokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw KimiK2TokenStoreError.keychainStatus(addStatus) } } private func deleteTokenIfPresent() throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw KimiK2TokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/KimiTokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol KimiTokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum KimiTokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainKimiTokenStore: KimiTokenStoring { private static let log = CodexBarLog.logger(LogCategories.kimiTokenStore) private let service = "com.steipete.CodexBar" private let account = "kimi-auth-token" func loadToken() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token load") return nil } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .kimiToken, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw KimiTokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw KimiTokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let token, !token.isEmpty { return token } return nil } func storeToken(_ token: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token store") return } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw KimiTokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw KimiTokenStoreError.keychainStatus(addStatus) } } private func deleteTokenIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw KimiTokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/LaunchAtLoginManager.swift ================================================ import CodexBarCore import ServiceManagement enum LaunchAtLoginManager { private static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil }() static func setEnabled(_ enabled: Bool) { if self.isRunningTests { return } let service = SMAppService.mainApp do { if enabled { try service.register() } else { try service.unregister() } } catch { CodexBarLog.logger(LogCategories.launchAtLogin).error("Failed to update login item: \(error)") } } } ================================================ FILE: Sources/CodexBar/LoadingPattern.swift ================================================ import Foundation enum LoadingPattern: String, CaseIterable, Identifiable { case knightRider case cylon case outsideIn case race case pulse case unbraid var id: String { self.rawValue } var displayName: String { switch self { case .knightRider: "Knight Rider" case .cylon: "Cylon" case .outsideIn: "Outside-In" case .race: "Race" case .pulse: "Pulse" case .unbraid: "Unbraid (logo → bars)" } } /// Secondary offset so the lower bar moves differently. var secondaryOffset: Double { switch self { case .knightRider: .pi case .cylon: .pi / 2 case .outsideIn: .pi case .race: .pi / 3 case .pulse: .pi / 2 case .unbraid: .pi / 2 } } func value(phase: Double) -> Double { let v: Double switch self { case .knightRider: v = 0.5 + 0.5 * sin(phase) // ping-pong case .cylon: let t = phase.truncatingRemainder(dividingBy: .pi * 2) / (.pi * 2) v = t // sawtooth 0→1 case .outsideIn: v = abs(cos(phase)) // high at edges, dip center case .race: let t = (phase * 1.2).truncatingRemainder(dividingBy: .pi * 2) / (.pi * 2) v = t case .pulse: v = 0.4 + 0.6 * (0.5 + 0.5 * sin(phase)) // 40–100% case .unbraid: v = 0.5 + 0.5 * sin(phase) // smooth 0→1 for morph } return max(0, min(v * 100, 100)) } } extension Notification.Name { static let codexbarDebugReplayAllAnimations = Notification.Name("codexbarDebugReplayAllAnimations") } ================================================ FILE: Sources/CodexBar/MenuBarDisplayMode.swift ================================================ import Foundation /// Controls what the menu bar displays when brand icon mode is enabled. enum MenuBarDisplayMode: String, CaseIterable, Identifiable { case percent case pace case both var id: String { self.rawValue } var label: String { switch self { case .percent: "Percent" case .pace: "Pace" case .both: "Both" } } var description: String { switch self { case .percent: "Show remaining/used percentage (e.g. 45%)" case .pace: "Show pace indicator (e.g. +5%)" case .both: "Show both percentage and pace (e.g. 45% · +5%)" } } } ================================================ FILE: Sources/CodexBar/MenuBarDisplayText.swift ================================================ import CodexBarCore import Foundation enum MenuBarDisplayText { static func percentText(window: RateWindow?, showUsed: Bool) -> String? { guard let window else { return nil } let percent = showUsed ? window.usedPercent : window.remainingPercent let clamped = min(100, max(0, percent)) return String(format: "%.0f%%", clamped) } static func paceText(pace: UsagePace?) -> String? { guard let pace else { return nil } let deltaValue = Int(abs(pace.deltaPercent).rounded()) let sign = pace.deltaPercent >= 0 ? "+" : "-" return "\(sign)\(deltaValue)%" } static func displayText( mode: MenuBarDisplayMode, percentWindow: RateWindow?, pace: UsagePace? = nil, showUsed: Bool) -> String? { switch mode { case .percent: return self.percentText(window: percentWindow, showUsed: showUsed) case .pace: return self.paceText(pace: pace) case .both: guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil } let paceText: String? = Self.paceText(pace: pace) guard let paceText else { return nil } return "\(percent) · \(paceText)" } } } ================================================ FILE: Sources/CodexBar/MenuCardView.swift ================================================ import AppKit import CodexBarCore import SwiftUI /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { enum PercentStyle: String { case left case used var labelSuffix: String { switch self { case .left: "left" case .used: "used" } } var accessibilityLabel: String { switch self { case .left: "Usage remaining" case .used: "Usage used" } } } struct Metric: Identifiable { let id: String let title: String let percent: Double let percentStyle: PercentStyle let resetText: String? let detailText: String? let detailLeftText: String? let detailRightText: String? let pacePercent: Double? let paceOnTop: Bool var percentLabel: String { String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) } } enum SubtitleStyle { case info case loading case error } struct TokenUsageSection { let sessionLine: String let monthLine: String let hintLine: String? let errorLine: String? let errorCopyText: String? } struct ProviderCostSection { let title: String let percentUsed: Double let spendLine: String } let provider: UsageProvider let providerName: String let email: String let subtitleText: String let subtitleStyle: SubtitleStyle let planText: String? let metrics: [Metric] let usageNotes: [String] let creditsText: String? let creditsRemaining: Double? let creditsHintText: String? let creditsHintCopyText: String? let providerCost: ProviderCostSection? let tokenUsage: TokenUsageSection? let placeholder: String? let progressColor: Color } let model: Model let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { return "API key limit" } return metric.title } var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) if self.hasDetails { Divider() } if self.model.metrics.isEmpty { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .font(.subheadline) } } else { let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { if hasUsage { VStack(alignment: .leading, spacing: 12) { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( metric: metric, title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } } } if hasUsage, hasCredits || hasCost { Divider() } if let credits = self.model.creditsText { CreditsBarContent( creditsText: credits, creditsRemaining: self.model.creditsRemaining, hintText: self.model.creditsHintText, hintCopyText: self.model.creditsHintCopyText, progressColor: self.model.progressColor) } if hasCredits, hasCost { Divider() } if let providerCost = self.model.providerCost { ProviderCostContent( section: providerCost, progressColor: self.model.progressColor) } if hasProviderCost, self.model.tokenUsage != nil { Divider() } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { Text("Cost") .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) .font(.footnote) Text(tokenUsage.monthLine) .font(.footnote) if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } if let error = tokenUsage.errorLine, !error.isEmpty { Text(error) .font(.footnote) .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) .overlay { ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) } } } } } .padding(.bottom, self.model.creditsText == nil ? 6 : 0) } } .padding(.horizontal, 16) .padding(.top, 2) .padding(.bottom, 2) .frame(width: self.width, alignment: .leading) } private var hasDetails: Bool { !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil || self.model.providerCost != nil } } private struct UsageMenuCardHeaderView: View { let model: UsageMenuCardView.Model @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { Text(self.model.providerName) .font(.headline) .fontWeight(.semibold) Spacer() Text(self.model.email) .font(.subheadline) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline HStack(alignment: subtitleAlignment) { Text(self.model.subtitleText) .font(.footnote) .foregroundStyle(self.subtitleColor) .lineLimit(self.model.subtitleStyle == .error ? 4 : 1) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .layoutPriority(1) .padding(.bottom, self.model.subtitleStyle == .error ? 4 : 0) Spacer() if self.model.subtitleStyle == .error, !self.model.subtitleText.isEmpty { CopyIconButton(copyText: self.model.subtitleText, isHighlighted: self.isHighlighted) } if let plan = self.model.planText { Text(plan) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } } } private var subtitleColor: Color { switch self.model.subtitleStyle { case .info: MenuHighlightStyle.secondary(self.isHighlighted) case .loading: MenuHighlightStyle.secondary(self.isHighlighted) case .error: MenuHighlightStyle.error(self.isHighlighted) } } } private struct CopyIconButtonStyle: ButtonStyle { let isHighlighted: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .padding(4) .background { RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(configuration.isPressed ? 0.18 : 0)) } .scaleEffect(configuration.isPressed ? 0.94 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } } private struct CopyIconButton: View { let copyText: String let isHighlighted: Bool @State private var didCopy = false @State private var resetTask: Task? var body: some View { Button { self.copyToPasteboard() withAnimation(.easeOut(duration: 0.12)) { self.didCopy = true } self.resetTask?.cancel() self.resetTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.9)) withAnimation(.easeOut(duration: 0.2)) { self.didCopy = false } } } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) .accessibilityLabel(self.didCopy ? "Copied" : "Copy error") } private func copyToPasteboard() { let pb = NSPasteboard.general pb.clearContents() pb.setString(self.copyText, forType: .string) } } private struct ProviderCostContent: View { let section: UsageMenuCardView.Model.ProviderCostSection let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.section.title) .font(.body) .fontWeight(.medium) UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, accessibilityLabel: "Extra usage spent") HStack(alignment: .firstTextBaseline) { Text(self.section.spendLine) .font(.footnote) Spacer() Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed)))) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } } } } private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.title) .font(.body) .fontWeight(.medium) UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) .font(.footnote) .lineLimit(1) Spacer() if let rightLabel = self.metric.resetText { Text(rightLabel) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } if self.metric.detailLeftText != nil || self.metric.detailRightText != nil { HStack(alignment: .firstTextBaseline) { if let detailLeft = self.metric.detailLeftText { Text(detailLeft) .font(.footnote) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) } Spacer() if let detailRight = self.metric.detailRightText { Text(detailRight) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } } } .frame(maxWidth: .infinity, alignment: .leading) if let detail = self.metric.detailText { Text(detail) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } } private struct UsageNotesContent: View { let notes: [String] @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 4) { ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in Text(note) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) } } struct UsageMenuCardHeaderSectionView: View { let model: UsageMenuCardView.Model let showDivider: Bool let width: CGFloat var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) if self.showDivider { Divider() } } .padding(.horizontal, 16) .padding(.top, 2) .padding(.bottom, self.model.subtitleStyle == .error ? 2 : 0) .frame(width: self.width, alignment: .leading) } } struct UsageMenuCardUsageSectionView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool let bottomPadding: CGFloat let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 12) { if self.model.metrics.isEmpty { if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .font(.subheadline) } } else { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( metric: metric, title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } } if self.showBottomDivider { Divider() } } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } struct UsageMenuCardCreditsSectionView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat var body: some View { if let credits = self.model.creditsText { VStack(alignment: .leading, spacing: 6) { CreditsBarContent( creditsText: credits, creditsRemaining: self.model.creditsRemaining, hintText: self.model.creditsHintText, hintCopyText: self.model.creditsHintCopyText, progressColor: self.model.progressColor) if self.showBottomDivider { Divider() } } .padding(.horizontal, 16) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } } private struct CreditsBarContent: View { private static let fullScaleTokens: Double = 1000 let creditsText: String let creditsRemaining: Double? let hintText: String? let hintCopyText: String? let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted private var percentLeft: Double? { guard let creditsRemaining else { return nil } let percent = (creditsRemaining / Self.fullScaleTokens) * 100 return min(100, max(0, percent)) } private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) return "\(scale) tokens" } var body: some View { VStack(alignment: .leading, spacing: 6) { Text("Credits") .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, accessibilityLabel: "Credits remaining") HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) Spacer() Text(self.scaleText) .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } } else { Text(self.creditsText) .font(.caption) } if let hintText, !hintText.isEmpty { Text(hintText) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) .overlay { ClickToCopyOverlay(copyText: self.hintCopyText ?? hintText) } } } } } struct UsageMenuCardCostSectionView: View { let model: UsageMenuCardView.Model let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { let hasTokenCost = self.model.tokenUsage != nil return Group { if hasTokenCost { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { Text("Cost") .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) .font(.caption) Text(tokenUsage.monthLine) .font(.caption) if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } if let error = tokenUsage.errorLine, !error.isEmpty { Text(error) .font(.footnote) .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) .overlay { ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) } } } } } .padding(.horizontal, 16) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } } } struct UsageMenuCardExtraUsageSectionView: View { let model: UsageMenuCardView.Model let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat var body: some View { Group { if let providerCost = self.model.providerCost { ProviderCostContent( section: providerCost, progressColor: self.model.progressColor) .padding(.horizontal, 16) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } } } // MARK: - Model factory extension UsageMenuCardView.Model { struct Input { let provider: UsageProvider let metadata: ProviderMetadata let snapshot: UsageSnapshot? let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? let account: AccountInfo let isRefreshing: Bool let lastError: String? let usageBarsShowUsed: Bool let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool let weeklyPace: UsagePace? let now: Date init( provider: UsageProvider, metadata: ProviderMetadata, snapshot: UsageSnapshot?, credits: CreditsSnapshot?, creditsError: String?, dashboard: OpenAIDashboardSnapshot?, dashboardError: String?, tokenSnapshot: CostUsageTokenSnapshot?, tokenError: String?, account: AccountInfo, isRefreshing: Bool, lastError: String?, usageBarsShowUsed: Bool, resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, weeklyPace: UsagePace? = nil, now: Date) { self.provider = provider self.metadata = metadata self.snapshot = snapshot self.credits = credits self.creditsError = creditsError self.dashboard = dashboard self.dashboardError = dashboardError self.tokenSnapshot = tokenSnapshot self.tokenError = tokenError self.account = account self.isRefreshing = isRefreshing self.lastError = lastError self.usageBarsShowUsed = usageBarsShowUsed self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo self.weeklyPace = weeklyPace self.now = now } } static func make(_ input: Input) -> UsageMenuCardView.Model { let planText = Self.plan( for: input.provider, snapshot: input.snapshot, account: input.account, metadata: input.metadata) let metrics = Self.metrics(input: input) let usageNotes = Self.usageNotes(input: input) let creditsText: String? = if input.provider == .openrouter { nil } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { nil } else { Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) } let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { nil } else { Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) } let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostUsageEnabled, snapshot: input.tokenSnapshot, error: input.tokenError) let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, lastError: input.lastError) let redacted = Self.redactedText(input: input, subtitle: subtitle) let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil return UsageMenuCardView.Model( provider: input.provider, providerName: input.metadata.displayName, email: redacted.email, subtitleText: redacted.subtitleText, subtitleStyle: subtitle.style, planText: planText, metrics: metrics, usageNotes: usageNotes, creditsText: creditsText, creditsRemaining: input.credits?.remaining, creditsHintText: redacted.creditsHintText, creditsHintCopyText: redacted.creditsHintCopyText, providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, progressColor: Self.progressColor(for: input.provider)) } private static func usageNotes(input: Input) -> [String] { if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) let resolvedSource = input.sourceLabel? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() if input.kiloAutoMode, resolvedSource == "cli", !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) { notes.append("Using CLI fallback") } return notes } guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { return [] } return switch openRouter.keyQuotaStatus { case .available: [] case .noLimitConfigured: ["No limit set for the API key"] case .unavailable: ["API key limit unavailable right now"] } } private static func email( for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, metadata: ProviderMetadata) -> String { if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } if metadata.usesAccountFallback, let email = account.email, !email.isEmpty { return email } return "" } private static func plan( for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, metadata: ProviderMetadata) -> String? { if provider == .kilo { guard let pass = self.kiloLoginPass(snapshot: snapshot) else { return nil } return self.planDisplay(pass) } if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty { return self.planDisplay(plan) } if metadata.usesAccountFallback, let plan = account.plan, !plan.isEmpty { return Self.planDisplay(plan) } return nil } private static func planDisplay(_ text: String) -> String { let cleaned = UsageFormatter.cleanPlanName(text) return cleaned.isEmpty ? text : cleaned } private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? { self.kiloLoginParts(snapshot: snapshot).pass } private static func kiloLoginDetails(snapshot: UsageSnapshot?) -> [String] { self.kiloLoginParts(snapshot: snapshot).details } private static func kiloLoginParts(snapshot: UsageSnapshot?) -> (pass: String?, details: [String]) { guard let loginMethod = snapshot?.loginMethod(for: .kilo) else { return (nil, []) } let parts = loginMethod .components(separatedBy: "·") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !parts.isEmpty else { return (nil, []) } let first = parts[0] if self.isKiloActivitySegment(first) { return (nil, parts) } return (first, Array(parts.dropFirst())) } private static func isKiloActivitySegment(_ text: String) -> Bool { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.hasPrefix("auto top-up:") } private static func subtitle( snapshot: UsageSnapshot?, isRefreshing: Bool, lastError: String?) -> (text: String, style: SubtitleStyle) { if let lastError, !lastError.isEmpty { return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) } if isRefreshing, snapshot == nil { return ("Refreshing...", .loading) } if let updated = snapshot?.updatedAt { return (UsageFormatter.updatedString(from: updated), .info) } return ("Not fetched yet", .info) } private struct RedactedText { let email: String let subtitleText: String let creditsHintText: String? let creditsHintCopyText: String? } private static func redactedText( input: Input, subtitle: (text: String, style: SubtitleStyle)) -> RedactedText { let email = PersonalInfoRedactor.redactEmail( Self.email( for: input.provider, snapshot: input.snapshot, account: input.account, metadata: input.metadata), isEnabled: input.hidePersonalInfo) let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) ?? subtitle.text let creditsHintText = PersonalInfoRedactor.redactEmails( in: Self.dashboardHint(provider: input.provider, error: input.dashboardError), isEnabled: input.hidePersonalInfo) let creditsHintCopyText = Self.creditsHintCopyText( dashboardError: input.dashboardError, hidePersonalInfo: input.hidePersonalInfo) return RedactedText( email: email, subtitleText: subtitleText, creditsHintText: creditsHintText, creditsHintCopyText: creditsHintCopyText) } private static func creditsHintCopyText(dashboardError: String?, hidePersonalInfo: Bool) -> String? { guard let dashboardError, !dashboardError.isEmpty else { return nil } return hidePersonalInfo ? "" : dashboardError } private static func metrics(input: Input) -> [Metric] { guard let snapshot = input.snapshot else { return [] } var metrics: [Metric] = [] let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) if let primary = snapshot.primary { var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) if input.provider == .openrouter, let openRouterQuotaDetail { primaryResetText = openRouterQuotaDetail } if input.provider == .warp || input.provider == .kilo, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail } if input.provider == .alibaba, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail } if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { primaryResetText = nil } metrics.append(Metric( id: "primary", title: input.metadata.sessionLabel, percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, resetText: primaryResetText, detailText: primaryDetailText, detailLeftText: nil, detailRightText: nil, pacePercent: nil, paceOnTop: true)) } if let weekly = snapshot.secondary { let paceDetail = Self.weeklyPaceDetail( window: weekly, now: input.now, pace: input.weeklyPace, showUsed: input.usageBarsShowUsed) var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil if input.provider == .warp, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { weeklyResetText = nil weeklyDetailText = detail } if input.provider == .kilo, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { weeklyDetailText = detail if weekly.resetsAt == nil { weeklyResetText = nil } } if input.provider == .alibaba, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { weeklyDetailText = detail } metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true)) } if input.provider == .kilo, metrics.contains(where: { $0.id == "primary" }), metrics.contains(where: { $0.id == "secondary" }) { metrics.sort { lhs, rhs in let kiloOrder: [String: Int] = [ "secondary": 0, "primary": 1, ] return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) } } if input.metadata.supportsOpus, let opus = snapshot.tertiary { var tertiaryDetailText: String? if input.provider == .alibaba, let detail = opus.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { tertiaryDetailText = detail } metrics.append(Metric( id: "tertiary", title: input.metadata.opusLabel ?? "Sonnet", percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), detailText: tertiaryDetailText, detailLeftText: nil, detailRightText: nil, pacePercent: nil, paceOnTop: true)) } if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", title: "Code review", percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, detailText: nil, detailLeftText: nil, detailRightText: nil, pacePercent: nil, paceOnTop: true)) } return metrics } private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { guard let limit else { return nil } if let currentValue = limit.currentValue, let usage = limit.usage, let remaining = limit.remaining { let currentStr = UsageFormatter.tokenCountString(currentValue) let usageStr = UsageFormatter.tokenCountString(usage) let remainingStr = UsageFormatter.tokenCountString(remaining) return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" } return nil } private static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { guard provider == .openrouter, let usage = snapshot.openRouterUsage, usage.hasValidKeyQuota, let keyRemaining = usage.keyRemaining, let keyLimit = usage.keyLimit else { return nil } let remaining = UsageFormatter.usdString(keyRemaining) let limit = UsageFormatter.usdString(keyLimit) return "\(remaining)/\(limit) left" } private struct PaceDetail { let leftLabel: String let rightLabel: String? let pacePercent: Double? let paceOnTop: Bool } private static func weeklyPaceDetail( window: RateWindow, now: Date, pace: UsagePace?, showUsed: Bool) -> PaceDetail? { guard let pace else { return nil } let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) let expectedUsed = detail.expectedUsedPercent let actualUsed = window.usedPercent let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) let actualPercent = showUsed ? actualUsed : (100 - actualUsed) if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } let paceOnTop = actualUsed <= expectedUsed let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, pacePercent: pacePercent, paceOnTop: paceOnTop) } private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, error: String?) -> String? { guard metadata.supportsCredits else { return nil } if let credits { return UsageFormatter.creditsString(from: credits.remaining) } if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } return metadata.creditsHint } private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { guard provider == .codex else { return nil } guard let error, !error.isEmpty else { return nil } return error } private static func tokenUsageSection( provider: UsageProvider, enabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } guard enabled else { return nil } guard let snapshot else { return nil } let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { return "Today: \(sessionCost) · \(sessionTokens) tokens" } return "Today: \(sessionCost)" }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { return "Last 30 days: \(monthCost) · \(monthTokens) tokens" } return "Last 30 days: \(monthCost)" }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, hintLine: nil, errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } private static func providerCostSection( provider: UsageProvider, cost: ProviderCostSnapshot?) -> ProviderCostSection? { guard let cost else { return nil } guard cost.limit > 0 else { return nil } let used: String let limit: String let title: String if cost.currencyCode == "Quota" { title = "Quota usage" used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { title = "Extra usage" used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) let periodLabel = cost.period ?? "This month" return ProviderCostSection( title: title, percentUsed: percentUsed, spendLine: "\(periodLabel): \(used) / \(limit)") } private static func clamped(_ value: Double) -> Double { min(100, max(0, value)) } private static func progressColor(for provider: UsageProvider) -> Color { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } private static func resetText( for window: RateWindow, style: ResetTimeDisplayStyle, now: Date) -> String? { UsageFormatter.resetLine(for: window, style: style, now: now) } } // MARK: - Copy-on-click overlay private struct ClickToCopyOverlay: NSViewRepresentable { let copyText: String func makeNSView(context: Context) -> ClickToCopyView { ClickToCopyView(copyText: self.copyText) } func updateNSView(_ nsView: ClickToCopyView, context: Context) { nsView.copyText = self.copyText } } private final class ClickToCopyView: NSView { var copyText: String init(copyText: String) { self.copyText = copyText super.init(frame: .zero) self.wantsLayer = false } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { _ = event let pb = NSPasteboard.general pb.clearContents() pb.setString(self.copyText, forType: .string) } } ================================================ FILE: Sources/CodexBar/MenuContent.swift ================================================ import AppKit import CodexBarCore import SwiftUI @MainActor struct MenuContent: View { @Bindable var store: UsageStore @Bindable var settings: SettingsStore let account: AccountInfo let updater: UpdaterProviding let provider: UsageProvider? let actions: MenuActions var body: some View { let descriptor = MenuDescriptor.build( provider: self.provider, store: self.store, settings: self.settings, account: self.account, updateReady: self.updater.updateStatus.isUpdateReady) VStack(alignment: .leading, spacing: 8) { ForEach(Array(descriptor.sections.enumerated()), id: \.offset) { index, section in VStack(alignment: .leading, spacing: 4) { ForEach(Array(section.entries.enumerated()), id: \.offset) { _, entry in self.row(for: entry) } } if index < descriptor.sections.count - 1 { Divider() } } } .padding(.horizontal, 10) .padding(.vertical, 6) .frame(minWidth: 260, alignment: .leading) } @ViewBuilder private func row(for entry: MenuDescriptor.Entry) -> some View { switch entry { case let .text(text, style): switch style { case .headline: Text(text).font(.headline) case .primary: Text(text) case .secondary: Text(text).foregroundStyle(.secondary).font(.footnote) } case let .action(title, action): Button { self.perform(action) } label: { if let icon = self.iconName(for: action) { HStack(spacing: 8) { Image(systemName: icon) .imageScale(.medium) .frame(width: 18, alignment: .center) Text(title) } .foregroundStyle(.primary) } else { Text(title) } } .buttonStyle(.plain) case .divider: Divider() } } private func iconName(for action: MenuDescriptor.MenuAction) -> String? { action.systemImageName } private func perform(_ action: MenuDescriptor.MenuAction) { switch action { case .refresh: self.actions.refresh() case .refreshAugmentSession: self.actions.refreshAugmentSession() case .installUpdate: self.actions.installUpdate() case .dashboard: self.actions.openDashboard() case .statusPage: self.actions.openStatusPage() case let .switchAccount(provider): self.actions.switchAccount(provider) case let .openTerminal(command): self.actions.openTerminal(command) case let .loginToProvider(url): if let urlObj = URL(string: url) { NSWorkspace.shared.open(urlObj) } case .settings: self.actions.openSettings() case .about: self.actions.openAbout() case .quit: self.actions.quit() case let .copyError(message): self.actions.copyError(message) } } } struct MenuActions { let installUpdate: () -> Void let refresh: () -> Void let refreshAugmentSession: () -> Void let openDashboard: () -> Void let openStatusPage: () -> Void let switchAccount: (UsageProvider) -> Void let openTerminal: (String) -> Void let openSettings: () -> Void let openAbout: () -> Void let quit: () -> Void let copyError: (String) -> Void } @MainActor struct StatusIconView: View { @Bindable var store: UsageStore let provider: UsageProvider var body: some View { Image(nsImage: self.icon) .renderingMode(.template) .interpolation(.none) } private var icon: NSImage { IconRenderer.makeIcon( primaryRemaining: self.store.snapshot(for: self.provider)?.primary?.remainingPercent, weeklyRemaining: self.store.snapshot(for: self.provider)?.secondary?.remainingPercent, creditsRemaining: self.provider == .codex ? self.store.credits?.remaining : nil, stale: self.store.isStale(provider: self.provider), style: self.store.style(for: self.provider), statusIndicator: self.store.statusIndicator(for: self.provider)) } } ================================================ FILE: Sources/CodexBar/MenuDescriptor.swift ================================================ import CodexBarCore import Foundation @MainActor struct MenuDescriptor { struct Section { var entries: [Entry] } enum Entry { case text(String, TextStyle) case action(String, MenuAction) case divider } enum MenuActionSystemImage: String { case refresh = "arrow.clockwise" case dashboard = "chart.bar" case statusPage = "waveform.path.ecg" case switchAccount = "key" case openTerminal = "terminal" case loginToProvider = "arrow.right.square" case settings = "gearshape" case about = "info.circle" case quit = "xmark.rectangle" case copyError = "doc.on.doc" } enum TextStyle { case headline case primary case secondary } enum MenuAction { case installUpdate case refresh case refreshAugmentSession case dashboard case statusPage case switchAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) case settings case about case quit case copyError(String) } var sections: [Section] static func build( provider: UsageProvider?, store: UsageStore, settings: SettingsStore, account: AccountInfo, updateReady: Bool, includeContextualActions: Bool = true) -> MenuDescriptor { var sections: [Section] = [] if let provider { sections.append(Self.usageSection(for: provider, store: store, settings: settings)) if let accountSection = Self.accountSection( for: provider, store: store, settings: settings, account: account) { sections.append(accountSection) } } else { var addedUsage = false for enabledProvider in store.enabledProviders() { sections.append(Self.usageSection(for: enabledProvider, store: store, settings: settings)) addedUsage = true } if addedUsage { if let accountProvider = Self.accountProviderForCombined(store: store), let accountSection = Self.accountSection( for: accountProvider, store: store, settings: settings, account: account) { sections.append(accountSection) } } else { sections.append(Section(entries: [.text("No usage configured.", .secondary)])) } } if includeContextualActions { let actions = Self.actionsSection(for: provider, store: store, account: account) if !actions.entries.isEmpty { sections.append(actions) } } sections.append(Self.metaSection(updateReady: updateReady)) return MenuDescriptor(sections: sections) } private static func usageSection( for provider: UsageProvider, store: UsageStore, settings: SettingsStore) -> Section { let meta = store.metadata(for: provider) var entries: [Entry] = [] let headlineText: String = { if let ver = Self.versionNumber(for: provider, store: store) { return "\(meta.displayName) \(ver)" } return meta.displayName }() entries.append(.text(headlineText, .headline)) if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { let primaryWindow = if provider == .warp || provider == .kilo { // Warp/Kilo primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits"). // Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, windowMinutes: primary.windowMinutes, resetsAt: primary.resetsAt, resetDescription: nil) } else { primary } Self.appendRateWindow( entries: &entries, title: meta.sessionLabel, window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) if provider == .warp || provider == .kilo, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { entries.append(.text(detail, .secondary)) } } if let weekly = snap.secondary { let weeklyResetOverride: String? = { guard provider == .warp || provider == .kilo else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) guard let detail, !detail.isEmpty else { return nil } if provider == .kilo, weekly.resetsAt != nil { return nil } return detail }() Self.appendRateWindow( entries: &entries, title: meta.weeklyLabel, window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, resetOverride: weeklyResetOverride) if provider == .kilo, weekly.resetsAt != nil, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { entries.append(.text(detail, .secondary)) } if let pace = store.weeklyPace(provider: provider, window: weekly) { let paceSummary = UsagePaceText.weeklySummary(pace: pace) entries.append(.text(paceSummary, .secondary)) } } if meta.supportsOpus, let opus = snap.tertiary { Self.appendRateWindow( entries: &entries, title: meta.opusLabel ?? "Sonnet", window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) } if let cost = snap.providerCost { if cost.currencyCode == "Quota" { let used = String(format: "%.0f", cost.used) let limit = String(format: "%.0f", cost.limit) entries.append(.text("Quota: \(used) / \(limit)", .primary)) } } } else { entries.append(.text("No usage yet", .secondary)) } let usageContext = ProviderMenuUsageContext( provider: provider, store: store, settings: settings, metadata: meta, snapshot: store.snapshot(for: provider)) ProviderCatalog.implementation(for: provider)? .appendUsageMenuEntries(context: usageContext, entries: &entries) return Section(entries: entries) } private static func accountSection( for provider: UsageProvider, store: UsageStore, settings: SettingsStore, account: AccountInfo) -> Section? { let snapshot = store.snapshot(for: provider) let metadata = store.metadata(for: provider) let entries = Self.accountEntries( provider: provider, snapshot: snapshot, metadata: metadata, fallback: account, hidePersonalInfo: settings.hidePersonalInfo) guard !entries.isEmpty else { return nil } return Section(entries: entries) } private static func accountEntries( provider: UsageProvider, snapshot: UsageSnapshot?, metadata: ProviderMetadata, fallback: AccountInfo, hidePersonalInfo: Bool) -> [Entry] { var entries: [Entry] = [] let emailText = snapshot?.accountEmail(for: provider)? .trimmingCharacters(in: .whitespacesAndNewlines) let loginMethodText = snapshot?.loginMethod(for: provider)? .trimmingCharacters(in: .whitespacesAndNewlines) let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) if let emailText, !emailText.isEmpty { entries.append(.text("Account: \(redactedEmail)", .secondary)) } if provider == .kilo { let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText) if let pass = kiloLogin.pass { entries.append(.text("Plan: \(AccountFormatter.plan(pass))", .secondary)) } for detail in kiloLogin.details { entries.append(.text("Activity: \(detail)", .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) } if metadata.usesAccountFallback { if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty { let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo) entries.append(.text("Account: \(redacted)", .secondary)) } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan))", .secondary)) } } return entries } private static func kiloLoginParts(loginMethod: String?) -> (pass: String?, details: [String]) { guard let loginMethod else { return (nil, []) } let parts = loginMethod .components(separatedBy: "·") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard !parts.isEmpty else { return (nil, []) } let first = parts[0] if self.isKiloActivitySegment(first) { return (nil, parts) } return (first, Array(parts.dropFirst())) } private static func isKiloActivitySegment(_ text: String) -> Bool { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.hasPrefix("auto top-up:") } private static func accountProviderForCombined(store: UsageStore) -> UsageProvider? { for provider in store.enabledProviders() { let metadata = store.metadata(for: provider) if store.snapshot(for: provider)?.identity(for: provider) != nil { return provider } if metadata.usesAccountFallback { return provider } } return nil } private static func actionsSection( for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Section { var entries: [Entry] = [] let targetProvider = provider ?? store.enabledProviders().first let metadata = targetProvider.map { store.metadata(for: $0) } let loginContext = targetProvider.map { ProviderMenuLoginContext( provider: $0, store: store, settings: store.settings, account: account) } // Show "Add Account" if no account, "Switch Account" if logged in if let targetProvider, let implementation = ProviderCatalog.implementation(for: targetProvider), implementation.supportsLoginFlow { if let loginContext, let override = implementation.loginMenuAction(context: loginContext) { entries.append(.action(override.label, override.action)) } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." entries.append(.action(accountLabel, loginAction)) } } if let targetProvider { let actionContext = ProviderMenuActionContext( provider: targetProvider, store: store, settings: store.settings, account: account) ProviderCatalog.implementation(for: targetProvider)? .appendActionMenuEntries(context: actionContext, entries: &entries) } if metadata?.dashboardURL != nil { entries.append(.action("Usage Dashboard", .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { entries.append(.action("Status Page", .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { entries.append(.text(statusLine, .secondary)) } return Section(entries: entries) } private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { entries.append(.action("Update ready, restart now?", .installUpdate)) } entries.append(contentsOf: [ .action("Settings...", .settings), .action("About CodexBar", .about), .action("Quit", .quit), ]) return Section(entries: entries) } private static func statusLine(for provider: UsageProvider?, store: UsageStore) -> String? { let target = provider ?? store.enabledProviders().first guard let target, let status = store.status(for: target), status.indicator != .none else { return nil } let description = status.description?.trimmingCharacters(in: .whitespacesAndNewlines) let label = description?.isEmpty == false ? description! : status.indicator.label if let updated = status.updatedAt { let freshness = UsageFormatter.updatedString(from: updated) return "\(label) — \(freshness)" } return label } private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction { if let provider { return .switchAccount(provider) } if let enabled = store.enabledProviders().first { return .switchAccount(enabled) } return .switchAccount(.codex) } private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool { let target = provider ?? store.enabledProviders().first ?? .codex if let email = store.snapshot(for: target)?.accountEmail(for: target), !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } let metadata = store.metadata(for: target) if metadata.usesAccountFallback, let fallback = account.email?.trimmingCharacters(in: .whitespacesAndNewlines), !fallback.isEmpty { return true } return false } private static func appendRateWindow( entries: inout [Entry], title: String, window: RateWindow, resetStyle: ResetTimeDisplayStyle, showUsed: Bool, resetOverride: String? = nil) { let line = UsageFormatter .usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed) entries.append(.text("\(title): \(line)", .primary)) if let resetOverride { entries.append(.text(resetOverride, .secondary)) } else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { entries.append(.text(reset, .secondary)) } } private static func versionNumber(for provider: UsageProvider, store: UsageStore) -> String? { guard let raw = store.version(for: provider) else { return nil } let pattern = #"[0-9]+(?:\.[0-9]+)*"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(raw.startIndex.. String { let cleaned = UsageFormatter.cleanPlanName(text) return cleaned.isEmpty ? text : cleaned } static func email(_ text: String) -> String { text } } extension MenuDescriptor.MenuAction { var systemImageName: String? { switch self { case .installUpdate, .settings, .about, .quit: nil case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue } } } ================================================ FILE: Sources/CodexBar/MenuHighlightStyle.swift ================================================ import SwiftUI extension EnvironmentValues { @Entry var menuItemHighlighted: Bool = false } enum MenuHighlightStyle { static let selectionText = Color(nsColor: .selectedMenuItemTextColor) static let normalPrimaryText = Color(nsColor: .controlTextColor) static let normalSecondaryText = Color(nsColor: .secondaryLabelColor) static func primary(_ highlighted: Bool) -> Color { highlighted ? self.selectionText : self.normalPrimaryText } static func secondary(_ highlighted: Bool) -> Color { highlighted ? self.selectionText : self.normalSecondaryText } static func error(_ highlighted: Bool) -> Color { highlighted ? self.selectionText : Color(nsColor: .systemRed) } static func progressTrack(_ highlighted: Bool) -> Color { highlighted ? self.selectionText.opacity(0.22) : Color(nsColor: .tertiaryLabelColor).opacity(0.22) } static func progressTint(_ highlighted: Bool, fallback: Color) -> Color { highlighted ? self.selectionText : fallback } static func selectionBackground(_ highlighted: Bool) -> Color { highlighted ? Color(nsColor: .selectedContentBackgroundColor) : .clear } } ================================================ FILE: Sources/CodexBar/MiniMaxAPITokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol MiniMaxAPITokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum MiniMaxAPITokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { private static let log = CodexBarLog.logger(LogCategories.minimaxAPITokenStore) private let service = "com.steipete.CodexBar" private let account = "minimax-api-token" func loadToken() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token load") return nil } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .minimaxToken, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw MiniMaxAPITokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw MiniMaxAPITokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let token, !token.isEmpty { return token } return nil } func storeToken(_ token: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token store") return } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteIfPresent() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw MiniMaxAPITokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxAPITokenStoreError.keychainStatus(addStatus) } } private func deleteIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw MiniMaxAPITokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/MiniMaxCookieStore.swift ================================================ import CodexBarCore import Foundation import Security protocol MiniMaxCookieStoring: Sendable { func loadCookieHeader() throws -> String? func storeCookieHeader(_ header: String?) throws } enum MiniMaxCookieStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { private static let log = CodexBarLog.logger(LogCategories.minimaxCookieStore) private let service = "com.steipete.CodexBar" private let account = "minimax-cookie" func loadCookieHeader() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping cookie load") return nil } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .minimaxCookie, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw MiniMaxCookieStoreError.keychainStatus(status) } guard let data = result as? Data else { throw MiniMaxCookieStoreError.invalidData } let header = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let header, !header.isEmpty { return header } return nil } func storeCookieHeader(_ header: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping cookie store") return } guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { try self.deleteIfPresent() return } guard MiniMaxCookieHeader.normalized(from: raw) != nil else { try self.deleteIfPresent() return } let data = raw.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw MiniMaxCookieStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxCookieStoreError.keychainStatus(addStatus) } } private func deleteIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw MiniMaxCookieStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/MouseLocationReader.swift ================================================ import AppKit import SwiftUI /// Lightweight NSView-based mouse tracking with local coordinates. /// /// Why: SwiftUI's `onHover` doesn't provide location, but we want "hover a bar to see values" on macOS. @MainActor struct MouseLocationReader: NSViewRepresentable { let onMoved: (CGPoint?) -> Void func makeNSView(context: Context) -> TrackingView { let view = TrackingView() view.onMoved = self.onMoved return view } func updateNSView(_ nsView: TrackingView, context: Context) { nsView.onMoved = self.onMoved } final class TrackingView: NSView { var onMoved: ((CGPoint?) -> Void)? private var trackingArea: NSTrackingArea? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() self.window?.acceptsMouseMovedEvents = true self.updateTrackingAreas() } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingArea { self.removeTrackingArea(trackingArea) } let options: NSTrackingArea.Options = [ // NSMenu popups aren't "key windows", so `.activeInKeyWindow` would drop events and cause hover // state to flicker. `.activeAlways` keeps tracking stable while the menu is open. .activeAlways, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved, ] let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) self.addTrackingArea(area) self.trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) self.onMoved?(self.convert(event.locationInWindow, from: nil)) } override func mouseMoved(with event: NSEvent) { super.mouseMoved(with: event) self.onMoved?(self.convert(event.locationInWindow, from: nil)) } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) self.onMoved?(nil) } } } ================================================ FILE: Sources/CodexBar/Notifications+CodexBar.swift ================================================ import Foundation extension Notification.Name { static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings") static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") } ================================================ FILE: Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift ================================================ import AppKit import CodexBarCore import WebKit @MainActor final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigationDelegate, WKScriptMessageHandler { private static let defaultSize = NSSize(width: 980, height: 760) private static let logHandlerName = "codexbarLog" private static let debugLogURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("codexbar-buy-credits.log") private static let autoStartScript = """ (() => { if (window.__codexbarAutoBuyCreditsStarted) return 'already'; const log = (...args) => { try { window.webkit?.messageHandlers?.codexbarLog?.postMessage(args); } catch {} }; const buttonSelector = 'button, a, [role="button"], input[type="button"], input[type="submit"]'; const isVisible = (el) => { if (!el || !el.getBoundingClientRect) return false; const rect = el.getBoundingClientRect(); if (rect.width < 2 || rect.height < 2) return false; const style = window.getComputedStyle ? window.getComputedStyle(el) : null; if (style) { if (style.display === 'none' || style.visibility === 'hidden') return false; if (parseFloat(style.opacity || '1') === 0) return false; } return true; }; const textOf = el => { const raw = el && (el.innerText || el.textContent) ? String(el.innerText || el.textContent) : ''; return raw.trim(); }; const matches = text => { const lower = String(text || '').toLowerCase(); if (!lower.includes('credit')) return false; return ( lower.includes('buy') || lower.includes('add') || lower.includes('purchase') || lower.includes('top up') || lower.includes('top-up') ); }; const matchesAddMore = text => { const lower = String(text || '').toLowerCase(); return lower.includes('add more'); }; const labelFor = el => { if (!el) return ''; return textOf(el) || el.getAttribute('aria-label') || el.getAttribute('title') || el.value || ''; }; const summarize = el => { if (!el) return null; return { tag: el.tagName, type: el.getAttribute('type'), role: el.getAttribute('role'), label: labelFor(el), aria: el.getAttribute('aria-label'), disabled: isDisabled(el), href: el.getAttribute('href'), testId: el.getAttribute('data-testid'), className: (el.className && String(el.className).slice(0, 120)) || '' }; }; const collectButtons = () => { const results = new Set(); const addAll = (root) => { if (!root || !root.querySelectorAll) return; root.querySelectorAll(buttonSelector).forEach(el => results.add(el)); }; addAll(document); document.querySelectorAll('*').forEach(el => { if (el.shadowRoot) addAll(el.shadowRoot); }); document.querySelectorAll('iframe').forEach(frame => { try { const doc = frame.contentDocument; if (!doc) return; addAll(doc); doc.querySelectorAll('*').forEach(el => { if (el.shadowRoot) addAll(el.shadowRoot); }); } catch {} }); return Array.from(results); }; const findDialogNextButton = () => { const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]'); if (!dialog) return null; const buttons = Array.from(dialog.querySelectorAll(buttonSelector)); const labeled = buttons.filter(btn => labelFor(btn).toLowerCase().startsWith('next')); const visible = labeled.find(isVisible); return visible || labeled[0] || null; }; const clickButton = (el) => { if (!el) return false; try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } catch { try { el.click(); } catch { return false; } } return true; }; const triggerPointerClick = (el) => { if (!el) return false; const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; if (!rect) return false; const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const events = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; for (const type of events) { try { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); } catch { return false; } } return true; }; const pickLikelyButton = (buttons) => { if (!buttons || buttons.length === 0) return null; const labeled = buttons.find(btn => { const label = labelFor(btn); if (matches(label) || matchesAddMore(label)) return true; const aria = String(btn.getAttribute('aria-label') || '').toLowerCase(); return aria.includes('credit') || aria.includes('buy') || aria.includes('add'); }); return labeled || buttons[0]; }; const findAddMoreButton = () => { const buttons = collectButtons(); return buttons.find(btn => matchesAddMore(labelFor(btn))) || null; }; const findNextButton = () => { const dialogNext = findDialogNextButton(); if (dialogNext) return dialogNext; const buttons = collectButtons(); const labeled = buttons.filter(btn => { const label = labelFor(btn).toLowerCase(); return label === 'next' || label.startsWith('next '); }); const visible = labeled.find(isVisible); if (visible) return visible; const submit = buttons.find(btn => btn.type && String(btn.type).toLowerCase() === 'submit' && isVisible(btn)); return submit || labeled[0] || null; }; const isDisabled = (el) => { if (!el) return true; if (el.disabled) return true; const ariaDisabled = String(el.getAttribute('aria-disabled') || '').toLowerCase(); if (ariaDisabled === 'true') return true; if (el.classList && (el.classList.contains('disabled') || el.classList.contains('is-disabled'))) { return true; } return false; }; const forceClickElement = (el) => { if (!el) return false; const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; if (rect) { const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const target = document.elementFromPoint(x, y); if (target) { try { target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; } catch { return false; } } } return false; }; const requestSubmit = (el) => { if (!el || !el.closest) return false; const form = el.closest('form'); if (!form) return false; if (typeof form.requestSubmit === 'function') { form.requestSubmit(el); return true; } if (typeof form.submit === 'function') { form.submit(); return true; } return false; }; const clickNextIfReady = (attempts) => { const nextButton = findNextButton(); if (!nextButton) { if (attempts && attempts % 5 === 0) log('next_missing', { attempts }); return false; } if (isDisabled(nextButton)) { if (attempts && attempts % 5 === 0) log('next_disabled', summarize(nextButton)); return false; } if (!isVisible(nextButton)) { if (attempts && attempts % 5 === 0) log('next_hidden', summarize(nextButton)); return false; } nextButton.focus?.(); if (requestSubmit(nextButton)) { log('next_submit', summarize(nextButton)); return true; } if (triggerPointerClick(nextButton)) { log('next_pointer', summarize(nextButton)); return true; } if (clickButton(nextButton)) { log('next_click', summarize(nextButton)); return true; } return forceClickElement(nextButton); }; const startNextPolling = (initialDelay = 500, interval = 500, maxAttempts = 90) => { if (window.__codexbarNextPolling) return; window.__codexbarNextPolling = true; log('start_next_poll', { initialDelay, interval, maxAttempts }); setTimeout(() => { let attempts = 0; const nextTimer = setInterval(() => { attempts += 1; if (attempts % 5 === 0) { const nextButton = findNextButton(); log('next_poll', { attempts, found: Boolean(nextButton), summary: summarize(nextButton) }); } if (clickNextIfReady(attempts) || attempts >= maxAttempts) { clearInterval(nextTimer); } }, interval); }, initialDelay); }; const observeNextButton = () => { if (window.__codexbarNextObserver || !window.MutationObserver) return; const observer = new MutationObserver(() => { if (clickNextIfReady(1)) { observer.disconnect(); window.__codexbarNextObserver = null; } }); observer.observe(document.body, { subtree: true, childList: true, attributes: true }); window.__codexbarNextObserver = observer; }; const findCreditsCardButton = () => { const nodes = Array.from(document.querySelectorAll('h1,h2,h3,div,span,p')); const labelMatch = nodes.find(node => { const lower = textOf(node).toLowerCase(); return lower === 'credits remaining' || (lower.includes('credits') && lower.includes('remaining')); }); if (!labelMatch) return null; let cur = labelMatch; for (let i = 0; i < 6 && cur; i++) { const buttons = Array.from(cur.querySelectorAll(buttonSelector)); const picked = pickLikelyButton(buttons); if (picked) return picked; cur = cur.parentElement; } return null; }; const findAndClick = () => { const addMoreButton = findAddMoreButton(); if (addMoreButton) { log('add_more_click', summarize(addMoreButton)); clickButton(addMoreButton); return true; } const cardButton = findCreditsCardButton(); if (!cardButton) return false; log('credits_card_click', summarize(cardButton)); return clickButton(cardButton); }; const logDialogButtons = () => { const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]'); if (dialog) { const buttons = Array.from(dialog.querySelectorAll(buttonSelector)).map(summarize).filter(Boolean); if (buttons.length) { log('dialog_buttons', { count: buttons.length, buttons: buttons.slice(0, 6) }); } const nextButton = findDialogNextButton(); if (nextButton) { log('dialog_next', summarize(nextButton)); setTimeout(() => clickNextIfReady(1), 100); } return; } const candidates = collectButtons() .map(summarize) .filter(Boolean) .filter(entry => { const label = (entry.label || '').toLowerCase(); return label.includes('next') || label.includes('continue') || label.includes('confirm') || label.includes('buy'); }); if (candidates.length) { log('button_candidates', { count: candidates.length, buttons: candidates.slice(0, 8) }); } }; log('auto_start', { href: location.href, ready: document.readyState }); const iframeSources = Array.from(document.querySelectorAll('iframe')) .map(frame => frame.getAttribute('src') || '') .filter(Boolean) .slice(0, 6); if (iframeSources.length) { log('iframes', iframeSources); } const shadowHostCount = Array.from(document.querySelectorAll('*')).filter(el => el.shadowRoot).length; if (shadowHostCount > 0) { log('shadow_roots', { count: shadowHostCount }); } if (findAndClick()) { window.__codexbarAutoBuyCreditsStarted = true; startNextPolling(); observeNextButton(); logDialogButtons(); return 'clicked'; } startNextPolling(500); observeNextButton(); logDialogButtons(); let attempts = 0; const maxAttempts = 14; const timer = setInterval(() => { attempts += 1; if (findAndClick()) { logDialogButtons(); startNextPolling(); clearInterval(timer); return; } if (attempts >= maxAttempts) { clearInterval(timer); } }, 500); window.__codexbarAutoBuyCreditsStarted = true; return 'scheduled'; })(); """ private let logger = CodexBarLog.logger(LogCategories.creditsPurchase) private var webView: WKWebView? private var accountEmail: String? private var pendingAutoStart = false private let logHandler = WeakScriptMessageHandler() init() { super.init(window: nil) self.logHandler.delegate = self } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func show(purchaseURL: URL, accountEmail: String?, autoStartPurchase: Bool) { let normalizedEmail = Self.normalizeEmail(accountEmail) if self.window == nil || normalizedEmail != self.accountEmail { self.accountEmail = normalizedEmail self.buildWindow() } Self.resetDebugLog() let accountValue = normalizedEmail == nil ? "none" : "set" let sanitizedURL = Self.sanitizedURLString(purchaseURL) Self.appendDebugLog( "show autoStart=\(autoStartPurchase) url=\(sanitizedURL) account=\(accountValue)") self.logger.info("Buy credits window opened") self.logger.debug("Auto-start purchase", metadata: ["enabled": autoStartPurchase ? "1" : "0"]) self.logger.debug("Purchase URL", metadata: ["url": sanitizedURL]) self.logger.debug("Account email", metadata: ["state": accountValue]) self.pendingAutoStart = autoStartPurchase self.load(url: purchaseURL) self.window?.center() self.showWindow(nil) NSApp.activate(ignoringOtherApps: true) } private func buildWindow() { let config = WKWebViewConfiguration() config.userContentController.add(self.logHandler, name: Self.logHandlerName) config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: self.accountEmail) let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = self webView.allowsBackForwardNavigationGestures = true webView.translatesAutoresizingMaskIntoConstraints = false let container = NSView(frame: .zero) container.addSubview(webView) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), webView.topAnchor.constraint(equalTo: container.topAnchor), webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) let window = NSWindow( contentRect: Self.defaultFrame(), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) window.title = "Buy Credits" window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] window.contentView = container window.center() window.delegate = self self.window = window self.webView = webView } private func load(url: URL) { guard let webView else { return } let request = URLRequest(url: url) webView.load(request) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard self.pendingAutoStart else { return } self.pendingAutoStart = false let currentURL = webView.url?.absoluteString ?? "unknown" Self.appendDebugLog("didFinish url=\(currentURL)") self.logger.debug("Buy credits navigation finished", metadata: ["url": currentURL]) webView.evaluateJavaScript(Self.autoStartScript) { [logger] result, error in if let error { Self.appendDebugLog("autoStart error=\(error.localizedDescription)") logger.error("Auto-start purchase failed", metadata: ["error": error.localizedDescription]) return } if let result { Self.appendDebugLog("autoStart result=\(String(describing: result))") logger.debug("Auto-start purchase result", metadata: ["result": String(describing: result)]) } } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == Self.logHandlerName else { return } let payload = String(describing: message.body) Self.appendDebugLog("js \(payload)") self.logger.debug("Auto-buy log", metadata: ["payload": payload]) } private static func normalizeEmail(_ email: String?) -> String? { guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return raw.lowercased() } private static func defaultFrame() -> NSRect { let visible = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) let width = min(Self.defaultSize.width, visible.width * 0.92) let height = min(Self.defaultSize.height, visible.height * 0.88) let origin = NSPoint(x: visible.midX - width / 2, y: visible.midY - height / 2) return NSRect(origin: origin, size: NSSize(width: width, height: height)) } private static func appendDebugLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(LogRedactor.redact(message))\n" guard let data = line.data(using: .utf8) else { return } if FileManager.default.fileExists(atPath: Self.debugLogURL.path) { if let handle = try? FileHandle(forWritingTo: Self.debugLogURL) { handle.seekToEndOfFile() handle.write(data) try? handle.close() } } else { try? data.write(to: Self.debugLogURL, options: .atomic) } } private static func resetDebugLog() { try? FileManager.default.removeItem(at: self.debugLogURL) } private static func sanitizedURLString(_ url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString } components.query = nil components.fragment = nil return components.string ?? url.absoluteString } } private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { weak var delegate: WKScriptMessageHandler? func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { self.delegate?.userContentController(userContentController, didReceive: message) } } // MARK: - NSWindowDelegate extension OpenAICreditsPurchaseWindowController: NSWindowDelegate { func windowWillClose(_ notification: Notification) { guard let window = self.window else { return } let webView = self.webView self.pendingAutoStart = false self.webView = nil self.window = nil self.logger.info("Buy credits window closing") WebKitTeardown.scheduleCleanup(owner: window, window: window, webView: webView) } } ================================================ FILE: Sources/CodexBar/PersonalInfoRedactor.swift ================================================ import Foundation enum PersonalInfoRedactor { static let emailPlaceholder = "Hidden" private static let emailRegex: NSRegularExpression? = { let pattern = #"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"# return try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) }() static func redactEmail(_ email: String?, isEnabled: Bool) -> String { guard let email, !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "" } guard isEnabled else { return email } return Self.emailPlaceholder } static func redactEmails(in text: String?, isEnabled: Bool) -> String? { guard let text else { return nil } guard isEnabled else { return text } guard let regex = Self.emailRegex else { return text } let range = NSRange(text.startIndex.. { Binding( get: { self.updateChannel }, set: { newValue in self.updateChannelRaw = newValue.rawValue self.updater.checkForUpdates(nil) }) } private func openProjectHome() { guard let url = URL(string: "https://github.com/steipete/CodexBar") else { return } NSWorkspace.shared.open(url) } } ================================================ FILE: Sources/CodexBar/PreferencesAdvancedPane.swift ================================================ import KeyboardShortcuts import SwiftUI @MainActor struct AdvancedPane: View { @Bindable var settings: SettingsStore @State private var isInstallingCLI = false @State private var cliStatus: String? var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { Text("Keyboard shortcut") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { Text("Open menu") .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } Text("Trigger the menu bar menu from anywhere.") .font(.footnote) .foregroundStyle(.tertiary) } Divider() SettingsSection(contentSpacing: 10) { HStack(spacing: 12) { Button { Task { await self.installCLI() } } label: { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { Text("Install CLI") } } .disabled(self.isInstallingCLI) if let status = self.cliStatus { Text(status) .font(.footnote) .foregroundStyle(.tertiary) .lineLimit(2) } } Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") .font(.footnote) .foregroundStyle(.tertiary) } Divider() SettingsSection(contentSpacing: 10) { PreferenceToggleRow( title: "Show Debug Settings", subtitle: "Expose troubleshooting tools in the Debug tab.", binding: self.$settings.debugMenuEnabled) PreferenceToggleRow( title: "Surprise me", subtitle: "Check if you like your agents having some fun up there.", binding: self.$settings.randomBlinkEnabled) } Divider() SettingsSection(contentSpacing: 10) { PreferenceToggleRow( title: "Hide personal information", subtitle: "Obscure email addresses in the menu bar and menu UI.", binding: self.$settings.hidePersonalInfo) } Divider() SettingsSection( title: "Keychain access", caption: """ Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \ headers manually in Providers. """) { PreferenceToggleRow( title: "Disable Keychain access", subtitle: "Prevents any Keychain access while enabled.", binding: self.$settings.debugDisableKeychainAccess) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 12) } } } extension AdvancedPane { private func installCLI() async { if self.isInstallingCLI { return } self.isInstallingCLI = true defer { self.isInstallingCLI = false } let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI") let fm = FileManager.default guard fm.fileExists(atPath: helperURL.path) else { self.cliStatus = "CodexBarCLI not found in app bundle." return } let destinations = [ "/usr/local/bin/codexbar", "/opt/homebrew/bin/codexbar", ] var results: [String] = [] for dest in destinations { let dir = (dest as NSString).deletingLastPathComponent guard fm.fileExists(atPath: dir) else { continue } guard fm.isWritableFile(atPath: dir) else { results.append("No write access: \(dir)") continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { results.append("Installed: \(dir)") } else { results.append("Exists: \(dir)") } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) results.append("Installed: \(dir)") } catch { results.append("Failed: \(dir)") } } self.cliStatus = results.isEmpty ? "No writable bin dirs found." : results.joined(separator: " · ") } private static func isLink(atPath path: String, pointingTo destination: String) -> Bool { guard let link = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else { return false } let dir = (path as NSString).deletingLastPathComponent let resolved = URL(fileURLWithPath: link, relativeTo: URL(fileURLWithPath: dir)) .standardizedFileURL .path return resolved == destination } } ================================================ FILE: Sources/CodexBar/PreferencesComponents.swift ================================================ import AppKit import SwiftUI @MainActor struct PreferenceToggleRow: View { let title: String let subtitle: String? @Binding var binding: Bool var body: some View { VStack(alignment: .leading, spacing: 5.4) { Toggle(isOn: self.$binding) { Text(self.title) .font(.body) } .toggleStyle(.checkbox) if let subtitle, !subtitle.isEmpty { Text(subtitle) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } } } } @MainActor struct SettingsSection: View { let title: String? let caption: String? let contentSpacing: CGFloat private let content: () -> Content init( title: String? = nil, caption: String? = nil, contentSpacing: CGFloat = 14, @ViewBuilder content: @escaping () -> Content) { self.title = title self.caption = caption self.contentSpacing = contentSpacing self.content = content } var body: some View { VStack(alignment: .leading, spacing: 10) { if let title, !title.isEmpty { Text(title) .font(.subheadline.weight(.semibold)) } if let caption { Text(caption) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: self.contentSpacing) { self.content() } .frame(maxWidth: .infinity, alignment: .leading) } } } @MainActor struct AboutLinkRow: View { let icon: String let title: String let url: String @State private var hovering = false var body: some View { Button { if let url = URL(string: self.url) { NSWorkspace.shared.open(url) } } label: { HStack(spacing: 8) { Image(systemName: self.icon) Text(self.title) .underline(self.hovering, color: .accentColor) } .frame(maxWidth: .infinity) .padding(.vertical, 4) .foregroundColor(.accentColor) } .buttonStyle(.plain) .contentShape(Rectangle()) .onHover { self.hovering = $0 } } } ================================================ FILE: Sources/CodexBar/PreferencesDebugPane.swift ================================================ import AppKit import CodexBarCore import SwiftUI @MainActor struct DebugPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore @AppStorage("debugFileLoggingEnabled") private var debugFileLoggingEnabled = false @State private var currentLogProvider: UsageProvider = .codex @State private var currentFetchProvider: UsageProvider = .codex @State private var isLoadingLog = false @State private var logText: String = "" @State private var isClearingCostCache = false @State private var costCacheStatus: String? #if DEBUG @State private var currentErrorProvider: UsageProvider = .codex @State private var simulatedErrorText: String = """ Simulated error for testing layout. Second line. Third line. Fourth line. """ #endif var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 20) { SettingsSection(title: "Logging") { PreferenceToggleRow( title: "Enable file logging", subtitle: "Write logs to \(self.fileLogPath) for debugging.", binding: self.$debugFileLoggingEnabled) .onChange(of: self.debugFileLoggingEnabled) { _, newValue in if self.settings.debugFileLoggingEnabled != newValue { self.settings.debugFileLoggingEnabled = newValue } } HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Verbosity") .font(.body) Text("Controls how much detail is logged.") .font(.footnote) .foregroundStyle(.tertiary) } Spacer() Picker("Verbosity", selection: self.$settings.debugLogLevel) { ForEach(CodexBarLog.Level.allCases) { level in Text(level.displayName).tag(level) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 160) } Button { NSWorkspace.shared.open(CodexBarLog.fileLogURL) } label: { Label("Open log file", systemImage: "doc.text.magnifyingglass") } .controlSize(.small) } SettingsSection { PreferenceToggleRow( title: "Force animation on next refresh", subtitle: "Temporarily shows the loading animation after the next refresh.", binding: self.$store.debugForceAnimation) } SettingsSection( title: "Loading animations", caption: "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.") { Picker("Animation pattern", selection: self.animationPatternBinding) { Text("Random (default)").tag(nil as LoadingPattern?) ForEach(LoadingPattern.allCases) { pattern in Text(pattern.displayName).tag(Optional(pattern)) } } .pickerStyle(.radioGroup) Button("Replay selected animation") { self.replaySelectedAnimation() } .keyboardShortcut(.defaultAction) Button { NotificationCenter.default.post(name: .codexbarDebugBlinkNow, object: nil) } label: { Label("Blink now", systemImage: "eyes") } .controlSize(.small) } SettingsSection( title: "Probe logs", caption: "Fetch the latest probe output for debugging; Copy keeps the full text.") { Picker("Provider", selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Cursor").tag(UsageProvider.cursor) Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) .frame(width: 460) HStack(spacing: 12) { Button { self.loadLog(self.currentLogProvider) } label: { Label("Fetch log", systemImage: "arrow.clockwise") } .disabled(self.isLoadingLog) Button { self.copyToPasteboard(self.logText) } label: { Label("Copy", systemImage: "doc.on.doc") } .disabled(self.logText.isEmpty) Button { self.saveLog(self.currentLogProvider) } label: { Label("Save to file", systemImage: "externaldrive.badge.plus") } .disabled(self.isLoadingLog && self.logText.isEmpty) if self.currentLogProvider == .claude { Button { self.loadClaudeDump() } label: { Label("Load parse dump", systemImage: "doc.text.magnifyingglass") } .disabled(self.isLoadingLog) } } Button { self.settings.rerunProviderDetection() self.loadLog(self.currentLogProvider) } label: { Label("Re-run provider autodetect", systemImage: "dot.radiowaves.left.and.right") } .controlSize(.small) ZStack(alignment: .topLeading) { ScrollView { Text(self.displayedLog) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .frame(minHeight: 160, maxHeight: 220) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) if self.isLoadingLog { ProgressView() .progressViewStyle(.circular) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding() } } } SettingsSection( title: "Fetch strategy attempts", caption: "Last fetch pipeline decisions and errors for a provider.") { Picker("Provider", selection: self.$currentFetchProvider) { ForEach(UsageProvider.allCases, id: \.self) { provider in Text(provider.rawValue.capitalized).tag(provider) } } .pickerStyle(.menu) .frame(width: 240) ScrollView { Text(self.fetchAttemptsText(for: self.currentFetchProvider)) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .frame(minHeight: 120, maxHeight: 220) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) } if !self.settings.debugDisableKeychainAccess { SettingsSection( title: "OpenAI cookies", caption: "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.") { HStack(spacing: 12) { Button { self.copyToPasteboard(self.store.openAIDashboardCookieImportDebugLog ?? "") } label: { Label("Copy", systemImage: "doc.on.doc") } .disabled((self.store.openAIDashboardCookieImportDebugLog ?? "").isEmpty) } ScrollView { Text( self.store.openAIDashboardCookieImportDebugLog?.isEmpty == false ? (self.store.openAIDashboardCookieImportDebugLog ?? "") : "No log yet. Update OpenAI cookies in Providers → Codex to run an import.") .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .frame(minHeight: 120, maxHeight: 180) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) } } SettingsSection( title: "Caches", caption: "Clear cached cost scan results.") { let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex) || self.store.isTokenRefreshInFlight(for: .claude) HStack(spacing: 12) { Button { Task { await self.clearCostCache() } } label: { Label("Clear cost cache", systemImage: "trash") } .disabled(self.isClearingCostCache || isTokenRefreshActive) if let status = self.costCacheStatus { Text(status) .font(.footnote) .foregroundStyle(.tertiary) } } } SettingsSection( title: "Notifications", caption: "Trigger test notifications for the 5-hour session window (depleted/restored).") { Picker("Provider", selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) } .pickerStyle(.segmented) .frame(width: 240) HStack(spacing: 12) { Button { self.postSessionNotification(.depleted, provider: self.currentLogProvider) } label: { Label("Post depleted", systemImage: "bell.badge") } .controlSize(.small) Button { self.postSessionNotification(.restored, provider: self.currentLogProvider) } label: { Label("Post restored", systemImage: "bell") } .controlSize(.small) } } SettingsSection( title: "CLI sessions", caption: "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.") { PreferenceToggleRow( title: "Keep CLI sessions alive", subtitle: "Skip teardown between probes (debug-only).", binding: self.$settings.debugKeepCLISessionsAlive) Button { Task { await CLIProbeSessionResetter.resetAll() } } label: { Label("Reset CLI sessions", systemImage: "arrow.counterclockwise") } .controlSize(.small) } #if DEBUG SettingsSection( title: "Error simulation", caption: "Inject a fake error message into the menu card for layout testing.") { Picker("Provider", selection: self.$currentErrorProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Gemini").tag(UsageProvider.gemini) Text("Antigravity").tag(UsageProvider.antigravity) Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) .frame(width: 360) TextField("Simulated error text", text: self.$simulatedErrorText, axis: .vertical) .lineLimit(4) HStack(spacing: 12) { Button { self.store._setErrorForTesting( self.simulatedErrorText, provider: self.currentErrorProvider) } label: { Label("Set menu error", systemImage: "exclamationmark.triangle") } .controlSize(.small) Button { self.store._setErrorForTesting(nil, provider: self.currentErrorProvider) } label: { Label("Clear menu error", systemImage: "xmark.circle") } .controlSize(.small) } let supportsTokenError = self.currentErrorProvider == .codex || self.currentErrorProvider == .claude HStack(spacing: 12) { Button { self.store._setTokenErrorForTesting( self.simulatedErrorText, provider: self.currentErrorProvider) } label: { Label("Set cost error", systemImage: "banknote") } .controlSize(.small) .disabled(!supportsTokenError) Button { self.store._setTokenErrorForTesting(nil, provider: self.currentErrorProvider) } label: { Label("Clear cost error", systemImage: "xmark.circle") } .controlSize(.small) .disabled(!supportsTokenError) } } #endif SettingsSection( title: "CLI paths", caption: "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).") { self.binaryRow(title: "Codex binary", value: self.store.pathDebugInfo.codexBinary) self.binaryRow(title: "Claude binary", value: self.store.pathDebugInfo.claudeBinary) VStack(alignment: .leading, spacing: 6) { Text("Effective PATH") .font(.callout.weight(.semibold)) ScrollView { Text( self.store.pathDebugInfo.effectivePATH.isEmpty ? "Unavailable" : self.store.pathDebugInfo.effectivePATH) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(6) } .frame(minHeight: 60, maxHeight: 110) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) } if let loginPATH = self.store.pathDebugInfo.loginShellPATH { VStack(alignment: .leading, spacing: 6) { Text("Login shell PATH (startup capture)") .font(.callout.weight(.semibold)) ScrollView { Text(loginPATH) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(6) } .frame(minHeight: 60, maxHeight: 110) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 12) } } private var fileLogPath: String { CodexBarLog.fileLogURL.path } private var animationPatternBinding: Binding { Binding( get: { self.settings.debugLoadingPattern }, set: { self.settings.debugLoadingPattern = $0 }) } private func replaySelectedAnimation() { var userInfo: [AnyHashable: Any] = [:] if let pattern = self.settings.debugLoadingPattern { userInfo["pattern"] = pattern.rawValue } NotificationCenter.default.post( name: .codexbarDebugReplayAllAnimations, object: nil, userInfo: userInfo.isEmpty ? nil : userInfo) self.store.replayLoadingAnimation(duration: 4) } private var displayedLog: String { if self.logText.isEmpty { return self.isLoadingLog ? "Loading…" : "No log yet. Fetch to load." } return self.logText } private func loadLog(_ provider: UsageProvider) { self.isLoadingLog = true Task { let text = await ProviderInteractionContext.$current.withValue(.userInitiated) { await ProviderRefreshContext.$current.withValue(.regular) { await self.store.debugLog(for: provider) } } await MainActor.run { self.logText = text self.isLoadingLog = false } } } private func saveLog(_ provider: UsageProvider) { Task { if self.logText.isEmpty { self.isLoadingLog = true let text = await ProviderInteractionContext.$current.withValue(.userInitiated) { await ProviderRefreshContext.$current.withValue(.regular) { await self.store.debugLog(for: provider) } } await MainActor.run { self.logText = text } self.isLoadingLog = false } _ = await ProviderInteractionContext.$current.withValue(.userInitiated) { await ProviderRefreshContext.$current.withValue(.regular) { await self.store.dumpLog(toFileFor: provider) } } } } private func copyToPasteboard(_ text: String) { let pb = NSPasteboard.general pb.clearContents() pb.setString(text, forType: .string) } private func binaryRow(title: String, value: String?) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.callout.weight(.semibold)) Text(value ?? "Not found") .font(.system(.footnote, design: .monospaced)) .foregroundStyle(value == nil ? .secondary : .primary) } } private func loadClaudeDump() { self.isLoadingLog = true Task { let text = await self.store.debugClaudeDump() await MainActor.run { self.logText = text self.isLoadingLog = false } } } private func postSessionNotification(_ transition: SessionQuotaTransition, provider: UsageProvider) { SessionQuotaNotifier().post(transition: transition, provider: provider, badge: 1) } private func clearCostCache() async { guard !self.isClearingCostCache else { return } self.isClearingCostCache = true self.costCacheStatus = nil defer { self.isClearingCostCache = false } if let error = await self.store.clearCostUsageCache() { self.costCacheStatus = "Failed: \(error)" return } self.costCacheStatus = "Cleared." } private func fetchAttemptsText(for provider: UsageProvider) -> String { let attempts = self.store.fetchAttempts(for: provider) guard !attempts.isEmpty else { return "No fetch attempts yet." } return attempts.map { attempt in let kind = Self.fetchKindLabel(attempt.kind) var line = "\(attempt.strategyID) (\(kind))" line += attempt.wasAvailable ? " available" : " unavailable" if let error = attempt.errorDescription, !error.isEmpty { line += " error=\(error)" } return line }.joined(separator: "\n") } private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String { switch kind { case .cli: "cli" case .web: "web" case .oauth: "oauth" case .apiToken: "api" case .localProbe: "local" case .webDashboard: "web" } } } ================================================ FILE: Sources/CodexBar/PreferencesDisplayPane.swift ================================================ import CodexBarCore import SwiftUI @MainActor struct DisplayPane: View { private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit @State private var isOverviewProviderPopoverPresented = false @Bindable var settings: SettingsStore @Bindable var store: UsageStore var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { Text("Menu bar") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( title: "Merge Icons", subtitle: "Use a single menu bar icon with a provider switcher.", binding: self.$settings.mergeIcons) PreferenceToggleRow( title: "Switcher shows icons", subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).", binding: self.$settings.switcherShowsIcons) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( title: "Show most-used provider", subtitle: "Menu bar auto-shows the provider closest to its rate limit.", binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( title: "Menu bar shows percent", subtitle: "Replace critter bars with provider branding icons and a percentage.", binding: self.$settings.menuBarShowsBrandIconWithPercent) HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Display mode") .font(.body) Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") .font(.footnote) .foregroundStyle(.tertiary) } Spacer() Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 200) } .disabled(!self.settings.menuBarShowsBrandIconWithPercent) .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) } Divider() SettingsSection(contentSpacing: 12) { Text("Menu content") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( title: "Show usage as used", subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( title: "Show reset time as clock", subtitle: "Display reset times as absolute clock values instead of countdowns.", binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( title: "Show credits + extra usage", subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", binding: self.$settings.showOptionalCreditsAndExtraUsage) PreferenceToggleRow( title: "Show all token accounts", subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", binding: self.$settings.showAllTokenAccountsInMenu) self.overviewProviderSelector } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 12) .onAppear { self.reconcileOverviewSelection() } .onChange(of: self.settings.mergeIcons) { _, isEnabled in guard isEnabled else { self.isOverviewProviderPopoverPresented = false return } self.reconcileOverviewSelection() } .onChange(of: self.activeProvidersInOrder) { _, _ in if self.activeProvidersInOrder.isEmpty { self.isOverviewProviderPopoverPresented = false } self.reconcileOverviewSelection() } } } private var overviewProviderSelector: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { Text("Overview tab providers") .font(.body) Spacer(minLength: 0) if self.showsOverviewConfigureButton { Button("Configure…") { self.isOverviewProviderPopoverPresented = true } .offset(y: 1) .popover(isPresented: self.$isOverviewProviderPopoverPresented, arrowEdge: .bottom) { self.overviewProviderPopover } } } if !self.settings.mergeIcons { Text("Enable Merge Icons to configure Overview tab providers.") .font(.footnote) .foregroundStyle(.tertiary) } else if self.activeProvidersInOrder.isEmpty { Text("No enabled providers available for Overview.") .font(.footnote) .foregroundStyle(.tertiary) } else { Text(self.overviewProviderSelectionSummary) .font(.footnote) .foregroundStyle(.tertiary) .lineLimit(2) .truncationMode(.tail) } } } private var overviewProviderPopover: some View { VStack(alignment: .leading, spacing: 10) { Text("Choose up to \(Self.maxOverviewProviders) providers") .font(.headline) Text("Overview rows always follow provider order.") .font(.footnote) .foregroundStyle(.tertiary) ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 6) { ForEach(self.activeProvidersInOrder, id: \.self) { provider in Toggle( isOn: Binding( get: { self.overviewSelectedProviders.contains(provider) }, set: { shouldSelect in self.setOverviewProviderSelection(provider: provider, isSelected: shouldSelect) })) { Text(self.providerDisplayName(provider)) .font(.body) } .toggleStyle(.checkbox) .disabled( !self.overviewSelectedProviders.contains(provider) && self.overviewSelectedProviders.count >= Self.maxOverviewProviders) } } } .frame(maxHeight: 220) } .padding(12) .frame(width: 280) } private var activeProvidersInOrder: [UsageProvider] { self.store.enabledProviders() } private var overviewSelectedProviders: [UsageProvider] { self.settings.resolvedMergedOverviewProviders( activeProviders: self.activeProvidersInOrder, maxVisibleProviders: Self.maxOverviewProviders) } private var showsOverviewConfigureButton: Bool { self.settings.mergeIcons && !self.activeProvidersInOrder.isEmpty } private var overviewProviderSelectionSummary: String { let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) guard !selectedNames.isEmpty else { return "No providers selected" } return selectedNames.joined(separator: ", ") } private func providerDisplayName(_ provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName } private func setOverviewProviderSelection(provider: UsageProvider, isSelected: Bool) { _ = self.settings.setMergedOverviewProviderSelection( provider: provider, isSelected: isSelected, activeProviders: self.activeProvidersInOrder, maxVisibleProviders: Self.maxOverviewProviders) } private func reconcileOverviewSelection() { _ = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: self.activeProvidersInOrder, maxVisibleProviders: Self.maxOverviewProviders) } } ================================================ FILE: Sources/CodexBar/PreferencesGeneralPane.swift ================================================ import AppKit import CodexBarCore import SwiftUI @MainActor struct GeneralPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { Text("System") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( title: "Start at Login", subtitle: "Automatically opens CodexBar when you start your Mac.", binding: self.$settings.launchAtLogin) } Divider() SettingsSection(contentSpacing: 12) { Text("Usage") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { Text("Show cost summary") .font(.body) } .toggleStyle(.checkbox) Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) if self.settings.costUsageEnabled { Text("Auto-refresh: hourly · Timeout: 10m") .font(.footnote) .foregroundStyle(.tertiary) self.costStatusLine(provider: .claude) self.costStatusLine(provider: .codex) } } } } Divider() SettingsSection(contentSpacing: 12) { Text("Automation") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Refresh cadence") .font(.body) Text("How often CodexBar polls providers in the background.") .font(.footnote) .foregroundStyle(.tertiary) } Spacer() Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { Text("Auto-refresh is off; use the menu's Refresh command.") .font(.footnote) .foregroundStyle(.secondary) } } PreferenceToggleRow( title: "Check provider status", subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " + "Gemini/Antigravity, surfacing incidents in the icon and menu.", binding: self.$settings.statusChecksEnabled) PreferenceToggleRow( title: "Session quota notifications", subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + "available again.", binding: self.$settings.sessionQuotaNotificationsEnabled) } Divider() SettingsSection(contentSpacing: 12) { HStack { Spacer() Button("Quit CodexBar") { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) .padding(.vertical, 12) } } private func costStatusLine(provider: UsageProvider) -> some View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { return Text("\(name): unsupported") .font(.footnote) .foregroundStyle(.tertiary) } if self.store.isTokenRefreshInFlight(for: provider) { let elapsed: String = { guard let startedAt = self.store.tokenLastAttemptAt(for: provider) else { return "" } let seconds = max(0, Date().timeIntervalSince(startedAt)) let formatter = DateComponentsFormatter() formatter.allowedUnits = seconds < 60 ? [.second] : [.minute, .second] formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() return Text("\(name): fetching…\(elapsed)") .font(.footnote) .foregroundStyle(.tertiary) } if let snapshot = self.store.tokenSnapshot(for: provider) { let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" return Text("\(name): \(updated) · 30d \(cost)") .font(.footnote) .foregroundStyle(.tertiary) } if let error = self.store.tokenError(for: provider), !error.isEmpty { let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) return Text("\(name): \(truncated)") .font(.footnote) .foregroundStyle(.tertiary) } if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) return Text("\(name): last attempt \(when)") .font(.footnote) .foregroundStyle(.tertiary) } return Text("\(name): no data yet") .font(.footnote) .foregroundStyle(.tertiary) } } ================================================ FILE: Sources/CodexBar/PreferencesProviderDetailView.swift ================================================ import CodexBarCore import SwiftUI @MainActor struct ProviderDetailView: View { let provider: UsageProvider @Bindable var store: UsageStore @Binding var isEnabled: Bool let subtitle: String let model: UsageMenuCardView.Model let settingsPickers: [ProviderSettingsPickerDescriptor] let settingsToggles: [ProviderSettingsToggleDescriptor] let settingsFields: [ProviderSettingsFieldDescriptor] let settingsTokenAccounts: ProviderSettingsTokenAccountsDescriptor? let errorDisplay: ProviderErrorDisplay? @Binding var isErrorExpanded: Bool let onCopyError: (String) -> Void let onRefresh: () -> Void static func metricTitle(provider: UsageProvider, metric: UsageMenuCardView.Model.Metric) -> String { UsageMenuCardView.popupMetricTitle(provider: provider, metric: metric) } static func planRow(provider: UsageProvider, planText: String?) -> (label: String, value: String)? { guard let rawPlan = planText?.trimmingCharacters(in: .whitespacesAndNewlines), !rawPlan.isEmpty else { return nil } guard provider == .openrouter else { return (label: "Plan", value: rawPlan) } let prefix = "Balance:" if rawPlan.hasPrefix(prefix) { let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count) let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedValue.isEmpty { return (label: "Balance", value: trimmedValue) } } return (label: "Balance", value: rawPlan) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { let labelWidth = self.detailLabelWidth ProviderDetailHeaderView( provider: self.provider, store: self.store, isEnabled: self.$isEnabled, subtitle: self.subtitle, model: self.model, labelWidth: labelWidth, onRefresh: self.onRefresh) ProviderMetricsInlineView( provider: self.provider, model: self.model, isEnabled: self.isEnabled, labelWidth: labelWidth) if let errorDisplay { ProviderErrorView( title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", display: errorDisplay, isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) } if self.hasSettings { ProviderSettingsSection(title: "Settings") { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } if let tokenAccounts = self.settingsTokenAccounts, tokenAccounts.isVisible?() ?? true { ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) } ForEach(self.settingsFields) { field in ProviderSettingsFieldRowView(field: field) } } } if !self.settingsToggles.isEmpty { ProviderSettingsSection(title: "Options") { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } } } } .frame(maxWidth: ProviderSettingsMetrics.detailMaxWidth, alignment: .leading) .padding(.vertical, 12) .padding(.horizontal, 8) } .frame(maxWidth: .infinity, alignment: .leading) } private var hasSettings: Bool { !self.settingsPickers.isEmpty || !self.settingsFields.isEmpty || self.settingsTokenAccounts != nil } private var detailLabelWidth: CGFloat { var infoLabels = ["State", "Source", "Version", "Updated"] if self.store.status(for: self.provider) != nil { infoLabels.append("Status") } if !self.model.email.isEmpty { infoLabels.append("Account") } if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) } var metricLabels = self.model.metrics.map { metric in Self.metricTitle(provider: self.provider, metric: metric) } if self.model.creditsText != nil { metricLabels.append("Credits") } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { metricLabels.append("Cost") } let infoWidth = ProviderSettingsMetrics.labelWidth( for: infoLabels, font: ProviderSettingsMetrics.infoLabelFont()) let metricWidth = ProviderSettingsMetrics.labelWidth( for: metricLabels, font: ProviderSettingsMetrics.metricLabelFont()) return max(infoWidth, metricWidth) } } @MainActor private struct ProviderDetailHeaderView: View { let provider: UsageProvider @Bindable var store: UsageStore @Binding var isEnabled: Bool let subtitle: String let model: UsageMenuCardView.Model let labelWidth: CGFloat let onRefresh: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 12) { ProviderDetailBrandIcon(provider: self.provider) VStack(alignment: .leading, spacing: 4) { Text(self.store.metadata(for: self.provider).displayName) .font(.title3.weight(.semibold)) Text(self.detailSubtitle) .font(.footnote) .foregroundStyle(.secondary) } Spacer(minLength: 12) Button { self.onRefresh() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.bordered) .controlSize(.small) .help("Refresh") Toggle("", isOn: self.$isEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) } ProviderDetailInfoGrid( provider: self.provider, store: self.store, isEnabled: self.isEnabled, model: self.model, labelWidth: self.labelWidth) } } private var detailSubtitle: String { let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false) guard lines.count >= 2 else { return self.subtitle } let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") let tail = rest.trimmingCharacters(in: .whitespacesAndNewlines) if tail.isEmpty { return String(first) } return "\(first) • \(tail)" } } @MainActor private struct ProviderDetailBrandIcon: View { let provider: UsageProvider var body: some View { if let brand = ProviderBrandIcon.image(for: self.provider) { Image(nsImage: brand) .resizable() .scaledToFit() .frame(width: 28, height: 28) .foregroundStyle(.secondary) .accessibilityHidden(true) } else { Image(systemName: "circle.dotted") .font(.system(size: 24, weight: .regular)) .foregroundStyle(.secondary) .accessibilityHidden(true) } } } @MainActor private struct ProviderDetailInfoGrid: View { let provider: UsageProvider @Bindable var store: UsageStore let isEnabled: Bool let model: UsageMenuCardView.Model let labelWidth: CGFloat var body: some View { let status = self.store.status(for: self.provider) let source = self.store.sourceLabel(for: self.provider) let version = self.store.version(for: self.provider) ?? "not detected" let updated = self.updatedText let email = self.model.email let enabledText = self.isEnabled ? "Enabled" : "Disabled" Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { ProviderDetailInfoRow(label: "State", value: enabledText, labelWidth: self.labelWidth) ProviderDetailInfoRow(label: "Source", value: source, labelWidth: self.labelWidth) ProviderDetailInfoRow(label: "Version", value: version, labelWidth: self.labelWidth) ProviderDetailInfoRow(label: "Updated", value: updated, labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( label: "Status", value: status.description ?? status.indicator.label, labelWidth: self.labelWidth) } if !email.isEmpty { ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) } if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { ProviderDetailInfoRow(label: planRow.label, value: planRow.value, labelWidth: self.labelWidth) } } .font(.footnote) .foregroundStyle(.secondary) } private var updatedText: String { if let updated = self.store.snapshot(for: self.provider)?.updatedAt { return UsageFormatter.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { return "Refreshing" } return "Not fetched yet" } } private struct ProviderDetailInfoRow: View { let label: String let value: String let labelWidth: CGFloat var body: some View { GridRow { Text(self.label) .frame(width: self.labelWidth, alignment: .leading) Text(self.value) .lineLimit(2) } } } @MainActor struct ProviderMetricsInlineView: View { let provider: UsageProvider let model: UsageMenuCardView.Model let isEnabled: Bool let labelWidth: CGFloat var body: some View { let hasMetrics = !self.model.metrics.isEmpty let hasUsageNotes = !self.model.usageNotes.isEmpty let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil ProviderSettingsSection( title: "Usage", spacing: 8, verticalPadding: 6, horizontalPadding: 0) { if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(self.model.metrics, id: \.id) { metric in ProviderMetricInlineRow( metric: metric, title: ProviderDetailView.metricTitle(provider: self.provider, metric: metric), progressColor: self.model.progressColor, labelWidth: self.labelWidth) } if hasUsageNotes { ProviderUsageNotesInlineView( notes: self.model.usageNotes, labelWidth: self.labelWidth, alignsWithMetricContent: hasMetrics) } if let credits = self.model.creditsText { ProviderMetricInlineTextRow( title: "Credits", value: credits, labelWidth: self.labelWidth) } if let providerCost = self.model.providerCost { ProviderMetricInlineCostRow( section: providerCost, progressColor: self.model.progressColor, labelWidth: self.labelWidth) } if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( title: "Cost", value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( title: "", value: tokenUsage.monthLine, labelWidth: self.labelWidth) } } } } private var placeholderText: String { if !self.isEnabled { return "Disabled — no recent data" } return self.model.placeholder ?? "No usage yet" } } private struct ProviderMetricInlineRow: View { let metric: UsageMenuCardView.Model.Metric let title: String let progressColor: Color let labelWidth: CGFloat var body: some View { HStack(alignment: .top, spacing: 10) { Text(self.title) .font(.subheadline.weight(.semibold)) .lineLimit(1) .frame(width: self.labelWidth, alignment: .leading) VStack(alignment: .leading, spacing: 4) { UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { Text(self.metric.percentLabel) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() Spacer(minLength: 8) if let resetText = self.metric.resetText, !resetText.isEmpty { Text(resetText) .font(.footnote) .foregroundStyle(.secondary) } } let hasLeftDetail = self.metric.detailLeftText?.isEmpty == false let hasRightDetail = self.metric.detailRightText?.isEmpty == false if hasLeftDetail || hasRightDetail { HStack(alignment: .firstTextBaseline, spacing: 8) { if let leftDetail = self.metric.detailLeftText, !leftDetail.isEmpty { Text(leftDetail) .font(.footnote) .foregroundStyle(.secondary) } Spacer(minLength: 8) if let rightDetail = self.metric.detailRightText, !rightDetail.isEmpty { Text(rightDetail) .font(.footnote) .foregroundStyle(.secondary) } } } if let detail = self.detailText, !detail.isEmpty { Text(detail) .font(.footnote) .foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } private var detailText: String? { guard let detailText = self.metric.detailText, !detailText.isEmpty else { return nil } return detailText } } private struct ProviderUsageNotesInlineView: View { let notes: [String] let labelWidth: CGFloat let alignsWithMetricContent: Bool var body: some View { HStack(alignment: .top, spacing: 10) { if self.alignsWithMetricContent { Spacer() .frame(width: self.labelWidth) } VStack(alignment: .leading, spacing: 4) { ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in Text(note) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } } private struct ProviderMetricInlineTextRow: View { let title: String let value: String let labelWidth: CGFloat var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(self.title) .font(.subheadline.weight(.semibold)) .frame(width: self.labelWidth, alignment: .leading) Text(self.value) .font(.footnote) .foregroundStyle(.secondary) Spacer(minLength: 0) } .padding(.vertical, 1) } } private struct ProviderMetricInlineCostRow: View { let section: UsageMenuCardView.Model.ProviderCostSection let progressColor: Color let labelWidth: CGFloat var body: some View { HStack(alignment: .top, spacing: 10) { Text(self.section.title) .font(.subheadline.weight(.semibold)) .frame(width: self.labelWidth, alignment: .leading) VStack(alignment: .leading, spacing: 4) { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, accessibilityLabel: "Usage used") .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { Text(String(format: "%.0f%% used", self.section.percentUsed)) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() Spacer(minLength: 8) Text(self.section.spendLine) .font(.footnote) .foregroundStyle(.secondary) } } Spacer(minLength: 0) } .padding(.vertical, 2) } } ================================================ FILE: Sources/CodexBar/PreferencesProviderErrorView.swift ================================================ import SwiftUI struct ProviderErrorDisplay { let preview: String let full: String } @MainActor struct ProviderErrorView: View { let title: String let display: ProviderErrorDisplay @Binding var isExpanded: Bool let onCopy: () -> Void var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(self.title) .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) Spacer() Button { self.onCopy() } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.plain) .foregroundStyle(.secondary) .help("Copy error") } Text(self.display.preview) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) if self.display.preview != self.display.full { Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() } .buttonStyle(.link) .font(.footnote) } if self.isExpanded { Text(self.display.full) .font(.footnote) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } .padding(.leading, 2) } } ================================================ FILE: Sources/CodexBar/PreferencesProviderSettingsMetrics.swift ================================================ import AppKit import SwiftUI enum ProviderSettingsMetrics { static let rowSpacing: CGFloat = 12 static let rowInsets = EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0) static let dividerBottomInset: CGFloat = 8 static let listTopPadding: CGFloat = 12 static let checkboxSize: CGFloat = 18 static let iconSize: CGFloat = 18 static let reorderHandleSize: CGFloat = 12 static let reorderDotSize: CGFloat = 2 static let reorderDotSpacing: CGFloat = 3 static let pickerLabelWidth: CGFloat = 92 static let sidebarWidth: CGFloat = 240 static let sidebarCornerRadius: CGFloat = 12 static let sidebarSubtitleHeight: CGFloat = { let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) let layout = NSLayoutManager() return ceil(layout.defaultLineHeight(for: font) * 2) }() static let detailMaxWidth: CGFloat = 640 static let metricLabelWidth: CGFloat = 120 static let metricBarWidth: CGFloat = 220 static func labelWidth(for labels: [String], font: NSFont, minimum: CGFloat = 0) -> CGFloat { let maxWidth = labels .filter { !$0.isEmpty } .map { ($0 as NSString).size(withAttributes: [.font: font]).width } .max() ?? 0 return max(minimum, ceil(maxWidth)) } static func metricLabelFont() -> NSFont { let baseSize = NSFont.preferredFont(forTextStyle: .subheadline).pointSize return NSFont.systemFont(ofSize: baseSize, weight: .semibold) } static func infoLabelFont() -> NSFont { NSFont.preferredFont(forTextStyle: .footnote) } } ================================================ FILE: Sources/CodexBar/PreferencesProviderSettingsRows.swift ================================================ import SwiftUI struct ProviderSettingsSection: View { let title: String let spacing: CGFloat let verticalPadding: CGFloat let horizontalPadding: CGFloat @ViewBuilder let content: () -> Content init( title: String, spacing: CGFloat = 12, verticalPadding: CGFloat = 10, horizontalPadding: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { self.title = title self.spacing = spacing self.verticalPadding = verticalPadding self.horizontalPadding = horizontalPadding self.content = content } var body: some View { VStack(alignment: .leading, spacing: self.spacing) { Text(self.title) .font(.headline) self.content() } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, self.verticalPadding) .padding(.horizontal, self.horizontalPadding) } } @MainActor struct ProviderSettingsToggleRowView: View { let toggle: ProviderSettingsToggleDescriptor var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(self.toggle.title) .font(.subheadline.weight(.semibold)) Text(self.toggle.subtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) Toggle("", isOn: self.toggle.binding) .labelsHidden() .toggleStyle(.switch) } if self.toggle.binding.wrappedValue { if let status = self.toggle.statusText?(), !status.isEmpty { Text(status) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } let actions = self.toggle.actions.filter { $0.isVisible?() ?? true } if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in Button(action.title) { Task { @MainActor in await action.perform() } } .applyProviderSettingsButtonStyle(action.style) .controlSize(.small) } } } } } .onChange(of: self.toggle.binding.wrappedValue) { _, enabled in guard let onChange = self.toggle.onChange else { return } Task { @MainActor in await onChange(enabled) } } .task(id: self.toggle.binding.wrappedValue) { guard self.toggle.binding.wrappedValue else { return } guard let onAppear = self.toggle.onAppearWhenEnabled else { return } await onAppear() } } } @MainActor struct ProviderSettingsPickerRowView: View { let picker: ProviderSettingsPickerDescriptor var body: some View { let isEnabled = self.picker.isEnabled?() ?? true VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(self.picker.title) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) Picker("", selection: self.picker.binding) { ForEach(self.picker.options) { option in Text(option.title).tag(option.id) } } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) if let trailingText = self.picker.trailingText?(), !trailingText.isEmpty { Text(trailingText) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .padding(.leading, 4) } Spacer(minLength: 0) } let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle if !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(subtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } .disabled(!isEnabled) .onChange(of: self.picker.binding.wrappedValue) { _, selection in guard let onChange = self.picker.onChange else { return } Task { @MainActor in await onChange(selection) } } } } @MainActor struct ProviderSettingsFieldRowView: View { let field: ProviderSettingsFieldDescriptor var body: some View { VStack(alignment: .leading, spacing: 8) { let trimmedTitle = self.field.title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedSubtitle = self.field.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) let hasHeader = !trimmedTitle.isEmpty || !trimmedSubtitle.isEmpty if hasHeader { VStack(alignment: .leading, spacing: 4) { if !trimmedTitle.isEmpty { Text(trimmedTitle) .font(.subheadline.weight(.semibold)) } if !trimmedSubtitle.isEmpty { Text(trimmedSubtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } switch self.field.kind { case .plain: TextField(self.field.placeholder ?? "", text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } case .secure: SecureField(self.field.placeholder ?? "", text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } } let actions = self.field.actions.filter { $0.isVisible?() ?? true } if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in Button(action.title) { Task { @MainActor in await action.perform() } } .applyProviderSettingsButtonStyle(action.style) .controlSize(.small) } } } } } } @MainActor struct ProviderSettingsTokenAccountsRowView: View { let descriptor: ProviderSettingsTokenAccountsDescriptor @State private var newLabel: String = "" @State private var newToken: String = "" var body: some View { VStack(alignment: .leading, spacing: 8) { Text(self.descriptor.title) .font(.subheadline.weight(.semibold)) if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.descriptor.subtitle) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } let accounts = self.descriptor.accounts() if accounts.isEmpty { Text("No token accounts yet.") .font(.footnote) .foregroundStyle(.secondary) } else { let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) Picker("", selection: Binding( get: { selectedIndex }, set: { index in self.descriptor.setActiveIndex(index) })) { ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in Text(account.displayName).tag(index) } } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) Button("Remove selected account") { let account = accounts[selectedIndex] self.descriptor.removeAccount(account.id) } .buttonStyle(.bordered) .controlSize(.small) } HStack(spacing: 8) { TextField("Label", text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) SecureField(self.descriptor.placeholder, text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) Button("Add") { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } self.descriptor.addAccount(label, token) self.newLabel = "" self.newToken = "" } .buttonStyle(.bordered) .controlSize(.small) .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } HStack(spacing: 10) { Button("Open token file") { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) Button("Reload") { self.descriptor.reloadFromDisk() } .buttonStyle(.link) .controlSize(.small) } } } } extension View { @ViewBuilder fileprivate func applyProviderSettingsButtonStyle(_ style: ProviderSettingsActionDescriptor.Style) -> some View { switch style { case .bordered: self.buttonStyle(.bordered) case .link: self.buttonStyle(.link) } } } ================================================ FILE: Sources/CodexBar/PreferencesProviderSidebarView.swift ================================================ import CodexBarCore import SwiftUI import UniformTypeIdentifiers @MainActor struct ProviderSidebarListView: View { let providers: [UsageProvider] @Bindable var store: UsageStore let isEnabled: (UsageProvider) -> Binding let subtitle: (UsageProvider) -> String @Binding var selection: UsageProvider? let moveProviders: (IndexSet, Int) -> Void @State private var draggingProvider: UsageProvider? var body: some View { List(selection: self.$selection) { ForEach(self.providers, id: \.self) { provider in ProviderSidebarRowView( provider: provider, store: self.store, isEnabled: self.isEnabled(provider), subtitle: self.subtitle(provider), draggingProvider: self.$draggingProvider) .tag(provider) .onDrop( of: [UTType.plainText], delegate: ProviderSidebarDropDelegate( item: provider, providers: self.providers, dragging: self.$draggingProvider, moveProviders: self.moveProviders)) } } .listStyle(.sidebar) .scrollContentBackground(.hidden) .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) .fill(.regularMaterial)) .overlay( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous)) .frame(minWidth: ProviderSettingsMetrics.sidebarWidth, maxWidth: ProviderSettingsMetrics.sidebarWidth) } } @MainActor private struct ProviderSidebarRowView: View { let provider: UsageProvider @Bindable var store: UsageStore @Binding var isEnabled: Bool let subtitle: String @Binding var draggingProvider: UsageProvider? var body: some View { let isRefreshing = self.store.refreshingProviders.contains(self.provider) let showStatus = self.store.statusChecksEnabled let statusText = self.statusText HStack(alignment: .center, spacing: 10) { ProviderSidebarReorderHandle() .contentShape(Rectangle()) .padding(.vertical, 4) .padding(.horizontal, 2) .help("Drag to reorder") .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) } ProviderSidebarBrandIcon(provider: self.provider) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(self.store.metadata(for: self.provider).displayName) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) if showStatus { ProviderStatusDot(indicator: self.store.statusIndicator(for: self.provider)) } if isRefreshing { ProgressView() .controlSize(.mini) } } Text(statusText) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) .frame(height: ProviderSettingsMetrics.sidebarSubtitleHeight, alignment: .topLeading) } Spacer(minLength: 8) Toggle("", isOn: self.$isEnabled) .labelsHidden() .toggleStyle(.checkbox) .controlSize(.small) } .contentShape(Rectangle()) .padding(.vertical, 2) } private var statusText: String { guard !self.isEnabled else { return self.subtitle } let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false) if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") return "Disabled — \(first)\n\(rest)" } return "Disabled — \(self.subtitle)" } } private struct ProviderSidebarReorderHandle: View { var body: some View { VStack(spacing: ProviderSettingsMetrics.reorderDotSpacing) { ForEach(0..<3, id: \.self) { _ in HStack(spacing: ProviderSettingsMetrics.reorderDotSpacing) { Circle() .frame( width: ProviderSettingsMetrics.reorderDotSize, height: ProviderSettingsMetrics.reorderDotSize) Circle() .frame( width: ProviderSettingsMetrics.reorderDotSize, height: ProviderSettingsMetrics.reorderDotSize) } } } .frame( width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) .accessibilityLabel("Reorder") } } @MainActor private struct ProviderSidebarBrandIcon: View { let provider: UsageProvider var body: some View { if let brand = ProviderBrandIcon.image(for: self.provider) { Image(nsImage: brand) .resizable() .scaledToFit() .frame(width: ProviderSettingsMetrics.iconSize, height: ProviderSettingsMetrics.iconSize) .foregroundStyle(.secondary) .accessibilityHidden(true) } else { Image(systemName: "circle.dotted") .font(.system(size: ProviderSettingsMetrics.iconSize, weight: .regular)) .foregroundStyle(.secondary) .accessibilityHidden(true) } } } private struct ProviderSidebarDropDelegate: DropDelegate { let item: UsageProvider let providers: [UsageProvider] @Binding var dragging: UsageProvider? let moveProviders: (IndexSet, Int) -> Void func dropEntered(info _: DropInfo) { guard let dragging, dragging != self.item else { return } guard let fromIndex = self.providers.firstIndex(of: dragging), let toIndex = self.providers.firstIndex(of: self.item) else { return } if fromIndex == toIndex { return } let adjustedIndex = toIndex > fromIndex ? toIndex + 1 : toIndex self.moveProviders(IndexSet(integer: fromIndex), adjustedIndex) } func dropUpdated(info _: DropInfo) -> DropProposal? { DropProposal(operation: .move) } func performDrop(info _: DropInfo) -> Bool { self.dragging = nil return true } } private struct ProviderStatusDot: View { let indicator: ProviderStatusIndicator var body: some View { Circle() .fill(self.statusColor) .frame(width: 6, height: 6) .accessibilityHidden(true) } private var statusColor: Color { switch self.indicator { case .none: .green case .minor: .yellow case .major: .orange case .critical: .red case .maintenance: .gray case .unknown: .gray } } } ================================================ FILE: Sources/CodexBar/PreferencesProvidersPane+Testing.swift ================================================ #if DEBUG import CodexBarCore import SwiftUI extension ProvidersPane { func _test_binding(for provider: UsageProvider) -> Binding { self.binding(for: provider) } func _test_providerSubtitle(_ provider: UsageProvider) -> String { self.providerSubtitle(provider) } func _test_menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { self.menuBarMetricPicker(for: provider) } func _test_tokenAccountDescriptor(for provider: UsageProvider) -> ProviderSettingsTokenAccountsDescriptor? { self.tokenAccountDescriptor(for: provider) } func _test_menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { self.menuCardModel(for: provider) } } @MainActor enum ProvidersPaneTestHarness { static func exercise(settings: SettingsStore, store: UsageStore) { self.prepareTestState(settings: settings, store: store) let pane = ProvidersPane(settings: settings, store: store) self.exercisePaneBasics(pane: pane) let descriptors = self.makeDescriptors() self.exerciseDetailViews(store: store, descriptors: descriptors) } private static func prepareTestState(settings: SettingsStore, store: UsageStore) { store.versions[.codex] = "1.0.0" store.versions[.claude] = "2.0.0 (build 123)" store.versions[.cursor] = nil store._setSnapshotForTesting( UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), provider: .codex) store._setSnapshotForTesting( UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), provider: .minimax) store._setErrorForTesting(String(repeating: "x", count: 200), provider: .cursor) store.lastSourceLabels[.minimax] = "cookies" store.refreshingProviders.insert(.codex) settings.claudeCookieSource = .manual settings.cursorCookieSource = .manual settings.opencodeCookieSource = .manual settings.factoryCookieSource = .manual settings.minimaxCookieSource = .manual settings.augmentCookieSource = .manual } private static func exercisePaneBasics(pane: ProvidersPane) { _ = pane._test_binding(for: .codex).wrappedValue _ = pane._test_providerSubtitle(.codex) _ = pane._test_providerSubtitle(.claude) _ = pane._test_providerSubtitle(.cursor) _ = pane._test_providerSubtitle(.opencode) _ = pane._test_providerSubtitle(.zai) _ = pane._test_providerSubtitle(.synthetic) _ = pane._test_providerSubtitle(.minimax) _ = pane._test_providerSubtitle(.kimi) _ = pane._test_providerSubtitle(.gemini) _ = pane._test_menuBarMetricPicker(for: .codex) _ = pane._test_menuBarMetricPicker(for: .gemini) _ = pane._test_menuBarMetricPicker(for: .zai) if let descriptor = pane._test_tokenAccountDescriptor(for: .claude) { _ = descriptor.isVisible?() _ = descriptor.accounts() } } private static func exerciseDetailViews(store: UsageStore, descriptors: ProviderListTestDescriptors) { var isEnabled = true let enabledBinding = Binding(get: { isEnabled }, set: { isEnabled = $0 }) let pane = ProvidersPane(settings: store.settings, store: store) let model = pane._test_menuCardModel(for: .codex) var expanded = false let expandedBinding = Binding(get: { expanded }, set: { expanded = $0 }) _ = ProviderDetailView( provider: .codex, store: store, isEnabled: enabledBinding, subtitle: "Subtitle", model: model, settingsPickers: [descriptors.picker], settingsToggles: [descriptors.toggle], settingsFields: [descriptors.fieldPlain, descriptors.fieldSecure], settingsTokenAccounts: descriptors.tokenAccountsEmpty, errorDisplay: ProviderErrorDisplay(preview: "Preview", full: "Full"), isErrorExpanded: expandedBinding, onCopyError: { _ in }, onRefresh: {}).body } private static func makeDescriptors() -> ProviderListTestDescriptors { let toggleBinding = Binding(get: { true }, set: { _ in }) let actionBordered = ProviderSettingsActionDescriptor( id: "action-bordered", title: "Bordered", style: .bordered, isVisible: { true }, perform: { await Task.yield() }) let actionLink = ProviderSettingsActionDescriptor( id: "action-link", title: "Link", style: .link, isVisible: { true }, perform: { await Task.yield() }) let toggle = ProviderSettingsToggleDescriptor( id: "toggle", title: "Toggle", subtitle: "Toggle subtitle", binding: toggleBinding, statusText: { "Status" }, actions: [actionBordered, actionLink], isVisible: { true }, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil) let picker = ProviderSettingsPickerDescriptor( id: "picker", title: "Picker", subtitle: "Picker subtitle", dynamicSubtitle: nil, binding: Binding(get: { "a" }, set: { _ in }), options: [ ProviderSettingsPickerOption(id: "a", title: "Option A"), ProviderSettingsPickerOption(id: "b", title: "Option B"), ], isVisible: { true }, onChange: nil, trailingText: { "Trailing" }) let fieldPlain = ProviderSettingsFieldDescriptor( id: "plain", title: "Field", subtitle: "Field subtitle", kind: .plain, placeholder: "Placeholder", binding: Binding(get: { "" }, set: { _ in }), actions: [actionBordered], isVisible: { true }, onActivate: nil) let fieldSecure = ProviderSettingsFieldDescriptor( id: "secure", title: "Secure", subtitle: "Secure subtitle", kind: .secure, placeholder: "Secure", binding: Binding(get: { "" }, set: { _ in }), actions: [actionLink], isVisible: { true }, onActivate: nil) let tokenAccountsEmpty = ProviderSettingsTokenAccountsDescriptor( id: "accounts-empty", title: "Accounts", subtitle: "Accounts subtitle", placeholder: "Token", provider: .codex, isVisible: { true }, accounts: { [] }, activeIndex: { 0 }, setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, openConfigFile: {}, reloadFromDisk: {}) return ProviderListTestDescriptors( toggle: toggle, picker: picker, fieldPlain: fieldPlain, fieldSecure: fieldSecure, tokenAccountsEmpty: tokenAccountsEmpty) } } private struct ProviderListTestDescriptors { let toggle: ProviderSettingsToggleDescriptor let picker: ProviderSettingsPickerDescriptor let fieldPlain: ProviderSettingsFieldDescriptor let fieldSecure: ProviderSettingsFieldDescriptor let tokenAccountsEmpty: ProviderSettingsTokenAccountsDescriptor } #endif ================================================ FILE: Sources/CodexBar/PreferencesProvidersPane.swift ================================================ import AppKit import CodexBarCore import SwiftUI @MainActor struct ProvidersPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore @State private var expandedErrors: Set = [] @State private var settingsStatusTextByID: [String: String] = [:] @State private var settingsLastAppActiveRunAtByID: [String: Date] = [:] @State private var activeConfirmation: ProviderSettingsConfirmationState? @State private var selectedProvider: UsageProvider? private var providers: [UsageProvider] { self.settings.orderedProviders() } var body: some View { HStack(alignment: .top, spacing: 16) { ProviderSidebarListView( providers: self.providers, store: self.store, isEnabled: { provider in self.binding(for: provider) }, subtitle: { provider in self.providerSubtitle(provider) }, selection: self.$selectedProvider, moveProviders: { fromOffsets, toOffset in self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) }) if let provider = self.selectedProvider ?? self.providers.first { ProviderDetailView( provider: provider, store: self.store, isEnabled: self.binding(for: provider), subtitle: self.providerSubtitle(provider), model: self.menuCardModel(for: provider), settingsPickers: self.extraSettingsPickers(for: provider), settingsToggles: self.extraSettingsToggles(for: provider), settingsFields: self.extraSettingsFields(for: provider), settingsTokenAccounts: self.tokenAccountDescriptor(for: provider), errorDisplay: self.providerErrorDisplay(provider), isErrorExpanded: self.expandedBinding(for: provider), onCopyError: { text in self.copyToPasteboard(text) }, onRefresh: { Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } } }) } else { Text("Select a provider") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .onAppear { self.ensureSelection() } .onChange(of: self.providers) { _, _ in self.ensureSelection() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in self.runSettingsDidBecomeActiveHooks() } .alert( self.activeConfirmation?.title ?? "", isPresented: Binding( get: { self.activeConfirmation != nil }, set: { isPresented in if !isPresented { self.activeConfirmation = nil } }), actions: { if let active = self.activeConfirmation { Button(active.confirmTitle) { active.onConfirm() self.activeConfirmation = nil } Button("Cancel", role: .cancel) { self.activeConfirmation = nil } } }, message: { if let active = self.activeConfirmation { Text(active.message) } }) } private func ensureSelection() { guard !self.providers.isEmpty else { self.selectedProvider = nil return } if let selected = self.selectedProvider, self.providers.contains(selected) { return } self.selectedProvider = self.providers.first } func binding(for provider: UsageProvider) -> Binding { let meta = self.store.metadata(for: provider) return Binding( get: { self.settings.isProviderEnabled(provider: provider, metadata: meta) }, set: { newValue in self.settings.setProviderEnabled(provider: provider, metadata: meta, enabled: newValue) }) } func providerSubtitle(_ provider: UsageProvider) -> String { let meta = self.store.metadata(for: provider) let usageText: String if let snapshot = self.store.snapshot(for: provider) { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else if self.store.isStale(provider: provider) { usageText = "last fetch failed" } else { usageText = "usage not fetched yet" } let presentationContext = ProviderPresentationContext( provider: provider, settings: self.settings, store: self.store, metadata: meta) let presentation = ProviderCatalog.implementation(for: provider)? .presentation(context: presentationContext) ?? ProviderPresentation(detailLine: ProviderPresentation.standardDetailLine) let detailLine = presentation.detailLine(presentationContext) return "\(detailLine)\n\(usageText)" } private func providerErrorDisplay(_ provider: UsageProvider) -> ProviderErrorDisplay? { guard let raw = self.store.error(for: provider), !raw.isEmpty else { return nil } return ProviderErrorDisplay( preview: self.truncated(raw, prefix: ""), full: raw) } private func extraSettingsToggles(for provider: UsageProvider) -> [ProviderSettingsToggleDescriptor] { guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } let context = self.makeSettingsContext(provider: provider) return impl.settingsToggles(context: context) .filter { $0.isVisible?() ?? true } } private func extraSettingsPickers(for provider: UsageProvider) -> [ProviderSettingsPickerDescriptor] { guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } let context = self.makeSettingsContext(provider: provider) let providerPickers = impl.settingsPickers(context: context) .filter { $0.isVisible?() ?? true } if let menuBarPicker = self.menuBarMetricPicker(for: provider) { return [menuBarPicker] + providerPickers } return providerPickers } private func extraSettingsFields(for provider: UsageProvider) -> [ProviderSettingsFieldDescriptor] { guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } let context = self.makeSettingsContext(provider: provider) return impl.settingsFields(context: context) .filter { $0.isVisible?() ?? true } } func tokenAccountDescriptor(for provider: UsageProvider) -> ProviderSettingsTokenAccountsDescriptor? { guard let support = TokenAccountSupportCatalog.support(for: provider) else { return nil } let context = self.makeSettingsContext(provider: provider) return ProviderSettingsTokenAccountsDescriptor( id: "token-accounts-\(provider.rawValue)", title: support.title, subtitle: support.subtitle, placeholder: support.placeholder, provider: provider, isVisible: { ProviderCatalog.implementation(for: provider)? .tokenAccountsVisibility(context: context, support: support) ?? (!support.requiresManualCookieSource || !context.settings.tokenAccounts(for: provider).isEmpty) }, accounts: { self.settings.tokenAccounts(for: provider) }, activeIndex: { let data = self.settings.tokenAccountsData(for: provider) return data?.clampedActiveIndex() ?? 0 }, setActiveIndex: { index in self.settings.setActiveTokenAccountIndex(index, for: provider) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } } }, addAccount: { label, token in self.settings.addTokenAccount(provider: provider, label: label, token: token) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } } }, removeAccount: { accountID in self.settings.removeTokenAccount(provider: provider, accountID: accountID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } } }, openConfigFile: { self.settings.openTokenAccountsFile() }, reloadFromDisk: { self.settings.reloadTokenAccounts() Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } } }) } private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext { ProviderSettingsContext( provider: provider, settings: self.settings, store: self.store, boolBinding: { keyPath in Binding( get: { self.settings[keyPath: keyPath] }, set: { self.settings[keyPath: keyPath] = $0 }) }, stringBinding: { keyPath in Binding( get: { self.settings[keyPath: keyPath] }, set: { self.settings[keyPath: keyPath] = $0 }) }, statusText: { id in self.settingsStatusTextByID[id] }, setStatusText: { id, text in if let text { self.settingsStatusTextByID[id] = text } else { self.settingsStatusTextByID.removeValue(forKey: id) } }, lastAppActiveRunAt: { id in self.settingsLastAppActiveRunAtByID[id] }, setLastAppActiveRunAt: { id, date in if let date { self.settingsLastAppActiveRunAtByID[id] = date } else { self.settingsLastAppActiveRunAtByID.removeValue(forKey: id) } }, requestConfirmation: { confirmation in self.activeConfirmation = ProviderSettingsConfirmationState(confirmation: confirmation) }) } func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { if provider == .zai { return nil } let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, title: "Primary (API key limit)"), ] } else { let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) var metricOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, title: "Primary (\(metadata.sessionLabel))"), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, title: "Secondary (\(metadata.weeklyLabel))"), ] if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) } options = metricOptions } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", title: "Menu bar metric", subtitle: "Choose which window drives the menu bar percent.", binding: Binding( get: { self.settings.menuBarMetricPreference(for: provider).rawValue }, set: { rawValue in guard let preference = MenuBarMetricPreference(rawValue: rawValue) else { return } self.settings.setMenuBarMetricPreference(preference, for: provider) }), options: options, isVisible: { true }, onChange: nil) } func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { let metadata = self.store.metadata(for: provider) let snapshot = self.store.snapshot(for: provider) let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? if provider == .codex { credits = self.store.credits creditsError = self.store.lastCreditsError dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: provider) tokenError = self.store.tokenError(for: provider) } else if provider == .claude || provider == .vertexai { credits = nil creditsError = nil dashboard = nil dashboardError = nil tokenSnapshot = self.store.tokenSnapshot(for: provider) tokenError = self.store.tokenError(for: provider) } else { credits = nil creditsError = nil dashboard = nil dashboardError = nil tokenSnapshot = nil tokenError = nil } let now = Date() let weeklyPace = snapshot?.secondary.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } let input = UsageMenuCardView.Model.Input( provider: provider, metadata: metadata, snapshot: snapshot, credits: credits, creditsError: creditsError, dashboard: dashboard, dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: self.store.accountInfo(), isRefreshing: self.store.refreshingProviders.contains(provider), lastError: self.store.error(for: provider), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) } private func runSettingsDidBecomeActiveHooks() { for provider in UsageProvider.allCases { for toggle in self.extraSettingsToggles(for: provider) { guard let hook = toggle.onAppDidBecomeActive else { continue } Task { @MainActor in await hook() } } } } private func truncated(_ text: String, prefix: String, maxLength: Int = 160) -> String { var message = text.trimmingCharacters(in: .whitespacesAndNewlines) if message.count > maxLength { let idx = message.index(message.startIndex, offsetBy: maxLength) message = "\(message[.. Binding { Binding( get: { self.expandedErrors.contains(provider) }, set: { expanded in if expanded { self.expandedErrors.insert(provider) } else { self.expandedErrors.remove(provider) } }) } private func copyToPasteboard(_ text: String) { let pb = NSPasteboard.general pb.clearContents() pb.setString(text, forType: .string) } } @MainActor struct ProviderSettingsConfirmationState: Identifiable { let id = UUID() let title: String let message: String let confirmTitle: String let onConfirm: () -> Void init(confirmation: ProviderSettingsConfirmation) { self.title = confirmation.title self.message = confirmation.message self.confirmTitle = confirmation.confirmTitle self.onConfirm = confirmation.onConfirm } } ================================================ FILE: Sources/CodexBar/PreferencesSelection.swift ================================================ import Foundation import Observation @MainActor @Observable final class PreferencesSelection { var tab: PreferencesTab = .general } ================================================ FILE: Sources/CodexBar/PreferencesView.swift ================================================ import AppKit import SwiftUI enum PreferencesTab: String, Hashable { case general case providers case display case advanced case about case debug static let defaultWidth: CGFloat = 496 static let providersWidth: CGFloat = 720 static let windowHeight: CGFloat = 580 var preferredWidth: CGFloat { self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth } var preferredHeight: CGFloat { PreferencesTab.windowHeight } } @MainActor struct PreferencesView: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore let updater: UpdaterProviding @Bindable var selection: PreferencesSelection @State private var contentWidth: CGFloat = PreferencesTab.general.preferredWidth @State private var contentHeight: CGFloat = PreferencesTab.general.preferredHeight var body: some View { TabView(selection: self.$selection.tab) { GeneralPane(settings: self.settings, store: self.store) .tabItem { Label("General", systemImage: "gearshape") } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) .tabItem { Label("Providers", systemImage: "square.grid.2x2") } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings, store: self.store) .tabItem { Label("Display", systemImage: "eye") } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) .tabItem { Label("About", systemImage: "info.circle") } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) .tabItem { Label("Debug", systemImage: "ladybug") } .tag(PreferencesTab.debug) } } .padding(.horizontal, 24) .padding(.vertical, 16) .frame(width: self.contentWidth, height: self.contentHeight) .onAppear { self.updateLayout(for: self.selection.tab, animate: false) self.ensureValidTabSelection() } .onChange(of: self.selection.tab) { _, newValue in self.updateLayout(for: newValue, animate: true) } .onChange(of: self.settings.debugMenuEnabled) { _, _ in self.ensureValidTabSelection() } } private func updateLayout(for tab: PreferencesTab, animate: Bool) { let change = { self.contentWidth = tab.preferredWidth self.contentHeight = tab.preferredHeight } if animate { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { change() } } else { change() } } private func ensureValidTabSelection() { if !self.settings.debugMenuEnabled, self.selection.tab == .debug { self.selection.tab = .general self.updateLayout(for: .general, animate: true) } } } ================================================ FILE: Sources/CodexBar/ProviderBrandIcon.swift ================================================ import AppKit import CodexBarCore enum ProviderBrandIcon { private static let size = NSSize(width: 16, height: 16) /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { // SwiftPM creates a CodexBar_CodexBar.bundle for resources in the CodexBar target. if let bundleURL = Bundle.main.url(forResource: "CodexBar_CodexBar", withExtension: "bundle"), let bundle = Bundle(url: bundleURL) { return bundle } // Fallback to main bundle for development/testing. return Bundle.main }() static func image(for provider: UsageProvider) -> NSImage? { let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName guard let bundle = self.resourceBundle, let url = bundle.url(forResource: baseName, withExtension: "svg"), let image = NSImage(contentsOf: url) else { return nil } image.size = self.size image.isTemplate = true return image } } ================================================ FILE: Sources/CodexBar/ProviderRegistry.swift ================================================ import CodexBarCore import Foundation struct ProviderSpec { let style: IconStyle let isEnabled: @MainActor () -> Bool let descriptor: ProviderDescriptor let makeFetchContext: @MainActor () -> ProviderFetchContext } struct ProviderRegistry { let metadata: [UsageProvider: ProviderMetadata] static let shared: ProviderRegistry = .init() init(metadata: [UsageProvider: ProviderMetadata] = ProviderDescriptorRegistry.metadata) { self.metadata = metadata } @MainActor func specs( settings: SettingsStore, metadata: [UsageProvider: ProviderMetadata], codexFetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, browserDetection: BrowserDetection) -> [UsageProvider: ProviderSpec] { var specs: [UsageProvider: ProviderSpec] = [:] specs.reserveCapacity(UsageProvider.allCases.count) for provider in UsageProvider.allCases { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let meta = metadata[provider]! let spec = ProviderSpec( style: descriptor.branding.iconStyle, isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) }, descriptor: descriptor, makeFetchContext: { let sourceMode = ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) ?? .auto let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) let env = Self.makeEnvironment( base: ProcessInfo.processInfo.environment, provider: provider, settings: settings, tokenOverride: nil) let verbose = settings.isVerboseLoggingEnabled return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, env: env, settings: snapshot, fetcher: codexFetcher, claudeFetcher: claudeFetcher, browserDetection: browserDetection) }) specs[provider] = spec } return specs } @MainActor static func makeSettingsSnapshot( settings: SettingsStore, tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot { settings.ensureTokenAccountsLoaded() var builder = ProviderSettingsSnapshotBuilder( debugMenuEnabled: settings.debugMenuEnabled, debugKeepCLISessionsAlive: settings.debugKeepCLISessionsAlive) let context = ProviderSettingsSnapshotContext(settings: settings, tokenOverride: tokenOverride) for implementation in ProviderCatalog.all { if let contribution = implementation.settingsSnapshot(context: context) { builder.apply(contribution) } } return builder.build() } @MainActor static func makeEnvironment( base: [String: String], provider: UsageProvider, settings: SettingsStore, tokenOverride: TokenAccountOverride?) -> [String: String] { let account = ProviderTokenAccountSelection.selectedAccount( provider: provider, settings: settings, override: tokenOverride) var env = ProviderConfigEnvironment.applyAPIKeyOverride( base: base, provider: provider, config: settings.providerConfig(for: provider)) // If token account is selected, use its token instead of config's apiKey if let account, let override = TokenAccountSupportCatalog.envOverride( for: provider, token: account.token) { for (key, value) in override { env[key] = value } } return env } } ================================================ FILE: Sources/CodexBar/ProviderSwitcherButtons.swift ================================================ import AppKit final class PaddedToggleButton: NSButton { var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) { didSet { if oldValue.top != self.contentPadding.top || oldValue.left != self.contentPadding.left || oldValue.bottom != self.contentPadding.bottom || oldValue.right != self.contentPadding.right { self.invalidateIntrinsicContentSize() } } } override var intrinsicContentSize: NSSize { let size = super.intrinsicContentSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, height: size.height + self.contentPadding.top + self.contentPadding.bottom) } } final class InlineIconToggleButton: NSButton { private let iconView = NSImageView() private let titleField = NSTextField(labelWithString: "") private let stack = NSStackView() private var paddingConstraints: [NSLayoutConstraint] = [] private var iconSizeConstraints: [NSLayoutConstraint] = [] private var isConfiguring = false // Batch invalidation during setup var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) { didSet { self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -(self.contentPadding.bottom + 4) if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } override var title: String { get { "" } set { super.title = "" super.alternateTitle = "" super.attributedTitle = NSAttributedString(string: "") super.attributedAlternateTitle = NSAttributedString(string: "") self.titleField.stringValue = newValue if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } override var image: NSImage? { get { nil } set { super.image = nil super.alternateImage = nil self.iconView.image = newValue if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } func setContentTintColor(_ color: NSColor?) { self.iconView.contentTintColor = color self.titleField.textColor = color } func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } func setAllowsTwoLineTitle(_ allow: Bool) { let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil let shouldWrap = allow && hasWhitespace self.titleField.maximumNumberOfLines = shouldWrap ? 2 : 1 self.titleField.usesSingleLineMode = !shouldWrap self.titleField.lineBreakMode = shouldWrap ? .byWordWrapping : .byTruncatingTail } override var intrinsicContentSize: NSSize { let size = self.stack.fittingSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, height: size.height + self.contentPadding.top + self.contentPadding.bottom) } init(title: String, image: NSImage, target: AnyObject?, action: Selector?) { super.init(frame: .zero) self.target = target self.action = action self.isConfiguring = true // Batch invalidations during setup self.configure() self.title = title self.image = image self.isConfiguring = false self.invalidateIntrinsicContentSize() // Single invalidation after setup } @available(*, unavailable) required init?(coder: NSCoder) { nil } private func configure() { self.bezelStyle = .regularSquare self.isBordered = false self.setButtonType(.toggle) self.controlSize = .small self.wantsLayer = true self.iconView.imageScaling = .scaleNone self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) self.titleField.alignment = .left self.titleField.lineBreakMode = .byTruncatingTail self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) self.titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) self.setContentTintColor(NSColor.secondaryLabelColor) self.stack.orientation = .horizontal self.stack.alignment = .centerY self.stack.spacing = 1 self.stack.translatesAutoresizingMaskIntoConstraints = false self.stack.addArrangedSubview(self.iconView) self.stack.addArrangedSubview(self.titleField) self.addSubview(self.stack) let iconWidth = self.iconView.widthAnchor.constraint(equalToConstant: 16) let iconHeight = self.iconView.heightAnchor.constraint(equalToConstant: 16) self.iconSizeConstraints = [iconWidth, iconHeight] let top = self.stack.topAnchor.constraint( equalTo: self.topAnchor, constant: self.contentPadding.top) let leading = self.stack.leadingAnchor.constraint( greaterThanOrEqualTo: self.leadingAnchor, constant: self.contentPadding.left) let trailing = self.stack.trailingAnchor.constraint( lessThanOrEqualTo: self.trailingAnchor, constant: -self.contentPadding.right) let centerX = self.stack.centerXAnchor.constraint(equalTo: self.centerXAnchor) centerX.priority = .defaultHigh let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, constant: -(self.contentPadding.bottom + 4)) self.paddingConstraints = [top, leading, trailing, bottom, centerX] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) } } final class StackedToggleButton: NSButton { private let iconView = NSImageView() private let titleField = NSTextField(labelWithString: "") private let stack = NSStackView() private var paddingConstraints: [NSLayoutConstraint] = [] private var iconSizeConstraints: [NSLayoutConstraint] = [] private var isConfiguring = false // Batch invalidation during setup var contentPadding = NSEdgeInsets(top: 2, left: 4, bottom: 2, right: 4) { didSet { self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } override var title: String { get { "" } set { super.title = "" super.alternateTitle = "" super.attributedTitle = NSAttributedString(string: "") super.attributedAlternateTitle = NSAttributedString(string: "") self.titleField.stringValue = newValue if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } override var image: NSImage? { get { nil } set { super.image = nil super.alternateImage = nil self.iconView.image = newValue if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } func setContentTintColor(_ color: NSColor?) { self.iconView.contentTintColor = color self.titleField.textColor = color } func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } func setAllowsTwoLineTitle(_ allow: Bool) { let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil let shouldWrap = allow && hasWhitespace self.titleField.maximumNumberOfLines = shouldWrap ? 2 : 1 self.titleField.usesSingleLineMode = !shouldWrap self.titleField.lineBreakMode = shouldWrap ? .byWordWrapping : .byTruncatingTail } override var intrinsicContentSize: NSSize { let size = self.stack.fittingSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, height: size.height + self.contentPadding.top + self.contentPadding.bottom) } init(title: String, image: NSImage, target: AnyObject?, action: Selector?) { super.init(frame: .zero) self.target = target self.action = action self.isConfiguring = true // Batch invalidations during setup self.configure() self.title = title self.image = image self.isConfiguring = false self.invalidateIntrinsicContentSize() // Single invalidation after setup } @available(*, unavailable) required init?(coder: NSCoder) { nil } private func configure() { self.bezelStyle = .regularSquare self.isBordered = false self.setButtonType(.toggle) self.controlSize = .small self.wantsLayer = true self.iconView.imageScaling = .scaleNone self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 2) self.titleField.alignment = .center self.titleField.lineBreakMode = .byTruncatingTail self.titleField.maximumNumberOfLines = 1 self.titleField.usesSingleLineMode = true self.setContentTintColor(NSColor.secondaryLabelColor) self.stack.orientation = .vertical self.stack.alignment = .centerX self.stack.spacing = 0 self.stack.translatesAutoresizingMaskIntoConstraints = false self.stack.addArrangedSubview(self.iconView) self.stack.addArrangedSubview(self.titleField) self.addSubview(self.stack) let iconWidth = self.iconView.widthAnchor.constraint(equalToConstant: 16) let iconHeight = self.iconView.heightAnchor.constraint(equalToConstant: 16) self.iconSizeConstraints = [iconWidth, iconHeight] // Avoid subpixel centering: pin from the top so the icon sits on whole-point coordinates. // Force an even layout width (button width minus padding) so the icon doesn't land on 0.5pt centers. // Reserve some bottom space for the "weekly remaining" indicator line. let top = self.stack.topAnchor.constraint( equalTo: self.topAnchor, constant: self.contentPadding.top) let leading = self.stack.leadingAnchor.constraint( equalTo: self.leadingAnchor, constant: self.contentPadding.left) let trailing = self.stack.trailingAnchor.constraint( equalTo: self.trailingAnchor, constant: -self.contentPadding.right) let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, constant: -(self.contentPadding.bottom + 4)) self.paddingConstraints = [top, leading, trailing, bottom] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) } } ================================================ FILE: Sources/CodexBar/ProviderToggleStore.swift ================================================ import CodexBarCore import Foundation struct ProviderToggleStore { private let userDefaults: UserDefaults private let key = "providerToggles" init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults } func isEnabled(metadata: ProviderMetadata) -> Bool { self.load()[metadata.cliName] ?? metadata.defaultEnabled } func setEnabled(_ enabled: Bool, metadata: ProviderMetadata) { var toggles = self.load() toggles[metadata.cliName] = enabled self.userDefaults.set(toggles, forKey: self.key) } private func load() -> [String: Bool] { (self.userDefaults.dictionary(forKey: self.key) as? [String: Bool]) ?? [:] } func purgeLegacyKeys() { self.userDefaults.removeObject(forKey: "showCodexUsage") self.userDefaults.removeObject(forKey: "showClaudeUsage") } } ================================================ FILE: Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { let id: UsageProvider = .alibaba @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in context.store.sourceLabel(for: context.provider) } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.alibabaCodingPlanAPIToken _ = settings.alibabaCodingPlanCookieSource _ = settings.alibabaCodingPlanCookieHeader _ = settings.alibabaCodingPlanAPIRegion } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context return .alibaba(context.settings.alibabaCodingPlanSettingsSnapshot()) } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let binding = Binding( get: { context.settings.alibabaCodingPlanAPIRegion.rawValue }, set: { raw in context.settings .alibabaCodingPlanAPIRegion = AlibabaCodingPlanAPIRegion(rawValue: raw) ?? .international }) let options = AlibabaCodingPlanAPIRegion.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } let cookieBinding = Binding( get: { context.settings.alibabaCodingPlanCookieSource.rawValue }, set: { raw in context.settings.alibabaCodingPlanCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.alibabaCodingPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies from Model Studio/Bailian.", manual: "Paste a Cookie header from modelstudio.console.alibabacloud.com.", off: "Alibaba cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "alibaba-coding-plan-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies from Model Studio/Bailian.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .alibaba) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ProviderSettingsPickerDescriptor( id: "alibaba-coding-plan-region", title: "Gateway region", subtitle: "Use international or China mainland console gateways for quota fetches.", binding: binding, options: options, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "alibaba-coding-plan-api-key", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio.", kind: .secure, placeholder: "cpk-...", binding: context.stringBinding(\.alibabaCodingPlanAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "alibaba-coding-plan-open-dashboard", title: "Open Coding Plan", style: .link, isVisible: nil, perform: { NSWorkspace.shared.open(context.settings.alibabaCodingPlanAPIRegion.dashboardURL) }), ], isVisible: nil, onActivate: { context.settings.ensureAlibabaCodingPlanAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "alibaba-coding-plan-cookie", title: "Cookie header", subtitle: "", kind: .secure, placeholder: "Cookie: ...", binding: context.stringBinding(\.alibabaCodingPlanCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "alibaba-coding-plan-open-dashboard-cookie", title: "Open Coding Plan", style: .link, isVisible: nil, perform: { NSWorkspace.shared.open(context.settings.alibabaCodingPlanAPIRegion.dashboardURL) }), ], isVisible: { context.settings.alibabaCodingPlanCookieSource == .manual }, onActivate: nil), ] } } ================================================ FILE: Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { private static let alibabaAutoEnableAppliedKey = "alibabaCodingPlanAutoEnableApplied" var alibabaCodingPlanAPIRegion: AlibabaCodingPlanAPIRegion { get { let raw = self.configSnapshot.providerConfig(for: .alibaba)?.region return AlibabaCodingPlanAPIRegion(rawValue: raw ?? "") ?? .international } set { self.updateProviderConfig(provider: .alibaba) { entry in entry.region = newValue.rawValue } } } var alibabaCodingPlanCookieHeader: String { get { self.configSnapshot.providerConfig(for: .alibaba)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .alibaba) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .alibaba, field: "cookieHeader", value: newValue) } } var alibabaCodingPlanCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .alibaba, fallback: .auto) } set { self.updateProviderConfig(provider: .alibaba) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .alibaba, field: "cookieSource", value: newValue.rawValue) } } var alibabaCodingPlanAPIToken: String { get { self.configSnapshot.providerConfig(for: .alibaba)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .alibaba) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } let hasToken = !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if hasToken, let metadata = ProviderDescriptorRegistry.metadata[.alibaba], !self.isProviderEnabled(provider: .alibaba, metadata: metadata) { self.setProviderEnabled(provider: .alibaba, metadata: metadata, enabled: true) } self.logSecretUpdate(provider: .alibaba, field: "apiKey", value: newValue) } } func ensureAlibabaCodingPlanAPITokenLoaded() {} func ensureAlibabaProviderAutoEnabledIfNeeded( environment: [String: String] = ProcessInfo.processInfo.environment) { guard self.userDefaults.bool(forKey: Self.alibabaAutoEnableAppliedKey) == false else { return } let hasConfigToken = self.configSnapshot.providerConfig(for: .alibaba)?.sanitizedAPIKey != nil let hasEnvironmentToken = AlibabaCodingPlanSettingsReader.apiToken(environment: environment) != nil guard hasConfigToken || hasEnvironmentToken else { return } if let metadata = ProviderDescriptorRegistry.metadata[.alibaba], !self.isProviderEnabled(provider: .alibaba, metadata: metadata) { self.setProviderEnabled(provider: .alibaba, metadata: metadata, enabled: true) } self.userDefaults.set(true, forKey: Self.alibabaAutoEnableAppliedKey) } } extension SettingsStore { func alibabaCodingPlanSettingsSnapshot() -> ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings { ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( cookieSource: self.alibabaCodingPlanCookieSource, manualCookieHeader: self.alibabaCodingPlanCookieHeader, apiRegion: self.alibabaCodingPlanAPIRegion) } } ================================================ FILE: Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct AmpProviderImplementation: ProviderImplementation { let id: UsageProvider = .amp @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.ampCookieSource _ = settings.ampCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .amp(context.settings.ampSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.ampCookieSource.rawValue }, set: { raw in context.settings.ampCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.ampCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies.", manual: "Paste a Cookie header or cURL capture from Amp settings.", off: "Amp cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "amp-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "amp-cookie", title: "", subtitle: "", kind: .secure, placeholder: "Cookie: …", binding: context.stringBinding(\.ampCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "amp-open-settings", title: "Open Amp Settings", style: .link, isVisible: nil, perform: { if let url = URL(string: "https://ampcode.com/settings") { NSWorkspace.shared.open(url) } }), ], isVisible: { context.settings.ampCookieSource == .manual }, onActivate: { context.settings.ensureAmpCookieLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/Amp/AmpSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var ampCookieHeader: String { get { self.configSnapshot.providerConfig(for: .amp)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .amp) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .amp, field: "cookieHeader", value: newValue) } } var ampCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .amp, fallback: .auto) } set { self.updateProviderConfig(provider: .amp) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .amp, field: "cookieSource", value: newValue.rawValue) } } func ensureAmpCookieLoaded() {} } extension SettingsStore { func ampSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.AmpProviderSettings { ProviderSettingsSnapshot.AmpProviderSettings( cookieSource: self.ampSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.ampSnapshotCookieHeader(tokenOverride: tokenOverride)) } private func ampSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.ampCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .amp), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .amp, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func ampSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.ampCookieSource guard let support = TokenAccountSupportCatalog.support(for: .amp), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .amp).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runAntigravityLoginFlow() async { self.loginPhase = .idle self.presentLoginAlert( title: "Antigravity login is managed in the app", message: "Open Antigravity to sign in, then refresh CodexBar.") } } ================================================ FILE: Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct AntigravityProviderImplementation: ProviderImplementation { let id: UsageProvider = .antigravity func detectVersion(context _: ProviderVersionContext) async -> String? { await AntigravityStatusProbe.detectVersion() } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runAntigravityLoginFlow() return false } } ================================================ FILE: Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct AugmentProviderImplementation: ProviderImplementation { let id: UsageProvider = .augment @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.augmentCookieSource _ = settings.augmentCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .augment(context.settings.augmentSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.augmentCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.augmentCookieSource != .manual { settings.augmentCookieSource = .manual } } func makeRuntime() -> (any ProviderRuntime)? { AugmentProviderRuntime() } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.augmentCookieSource.rawValue }, set: { raw in context.settings.augmentCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.augmentCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies.", manual: "Paste a Cookie header or cURL capture from the Augment dashboard.", off: "Augment cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "augment-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .augment) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { _ = context return [] } @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) { entries.append(.action("Refresh Session", .refreshAugmentSession)) if let error = context.store.error(for: .augment) { if error.contains("session has expired") || error.contains("No Augment session cookie found") { entries.append(.action( "Open Augment (Log Out & Back In)", .loginToProvider(url: "https://app.augmentcode.com"))) } } } } ================================================ FILE: Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift ================================================ import CodexBarCore import Foundation @MainActor final class AugmentProviderRuntime: ProviderRuntime { let id: UsageProvider = .augment private var keepalive: AugmentSessionKeepalive? func start(context: ProviderRuntimeContext) { self.updateKeepalive(context: context) } func stop(context: ProviderRuntimeContext) { self.stopKeepalive(context: context, reason: "provider disabled") } func settingsDidChange(context: ProviderRuntimeContext) { self.updateKeepalive(context: context) } func providerDidFail(context: ProviderRuntimeContext, provider: UsageProvider, error: Error) { guard provider == .augment else { return } let message = error.localizedDescription guard message.contains("session expired") else { return } context.store.augmentLogger.warning("Augment session expired; triggering recovery") Task { [weak self] in guard let self else { return } await self.forceRefresh(context: context) } } func perform(action: ProviderRuntimeAction, context: ProviderRuntimeContext) async { switch action { case .forceSessionRefresh: await self.forceRefresh(context: context) case .openAIWebAccessToggled: break } } private func updateKeepalive(context: ProviderRuntimeContext) { #if os(macOS) let shouldRun = context.store.isEnabled(.augment) let isRunning = self.keepalive != nil if shouldRun, !isRunning { self.startKeepalive(context: context) } else if !shouldRun, isRunning { self.stopKeepalive(context: context, reason: "provider disabled") } #endif } private func startKeepalive(context: ProviderRuntimeContext) { #if os(macOS) context.store.augmentLogger.info( "Augment keepalive check", metadata: [ "enabled": context.store.isEnabled(.augment) ? "1" : "0", "available": context.store.isProviderAvailable(.augment) ? "1" : "0", ]) guard context.store.isEnabled(.augment) else { context.store.augmentLogger.warning("Augment keepalive not started (provider disabled)") return } let logger: (String) -> Void = { [augmentLogger = context.store.augmentLogger] message in augmentLogger.verbose(message) } let onSessionRecovered: () async -> Void = { [weak store = context.store] in guard let store else { return } store.augmentLogger.info("Augment session recovered; refreshing usage") await store.refreshProvider(.augment) } self.keepalive = AugmentSessionKeepalive(logger: logger, onSessionRecovered: onSessionRecovered) self.keepalive?.start() context.store.augmentLogger.info("Augment keepalive started") #endif } private func stopKeepalive(context: ProviderRuntimeContext, reason: String) { #if os(macOS) self.keepalive?.stop() self.keepalive = nil context.store.augmentLogger.info("Augment keepalive stopped (\(reason))") #endif } private func forceRefresh(context: ProviderRuntimeContext) async { #if os(macOS) context.store.augmentLogger.info("Augment force refresh requested") guard let keepalive = self.keepalive else { context.store.augmentLogger.warning("Augment keepalive not running; starting") self.startKeepalive(context: context) try? await Task.sleep(for: .seconds(1)) guard let keepalive = self.keepalive else { context.store.augmentLogger.error("Augment keepalive failed to start") return } await keepalive.forceRefresh() return } await keepalive.forceRefresh() context.store.augmentLogger.info("Refreshing Augment usage after session refresh") await context.store.refreshProvider(.augment) #endif } } ================================================ FILE: Sources/CodexBar/Providers/Augment/AugmentSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var augmentCookieHeader: String { get { self.configSnapshot.providerConfig(for: .augment)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .augment) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .augment, field: "cookieHeader", value: newValue) } } var augmentCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .augment, fallback: .auto) } set { self.updateProviderConfig(provider: .augment) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .augment, field: "cookieSource", value: newValue.rawValue) } } func ensureAugmentCookieLoaded() {} } extension SettingsStore { func augmentSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .AugmentProviderSettings { ProviderSettingsSnapshot.AugmentProviderSettings( cookieSource: self.augmentSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.augmentSnapshotCookieHeader(tokenOverride: tokenOverride)) } private func augmentSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.augmentCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .augment), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .augment, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func augmentSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.augmentCookieSource guard let support = TokenAccountSupportCatalog.support(for: .augment), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .augment).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runClaudeLoginFlow() async { let phaseHandler: @Sendable (ClaudeLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { case .requesting: self?.loginPhase = .requesting case .waitingBrowser: self?.loginPhase = .waitingBrowser } } } let result = await ClaudeLoginRunner.run(timeout: 120, onPhaseChange: phaseHandler) guard !Task.isCancelled else { return } self.loginPhase = .idle self.presentClaudeLoginResult(result) let outcome = self.describe(result.outcome) let length = result.output.count self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) if case .success = result.outcome { self.postLoginNotification(for: .claude) } } } ================================================ FILE: Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import SwiftUI @ProviderImplementationRegistration struct ClaudeProviderImplementation: ProviderImplementation { let id: UsageProvider = .claude let supportsLoginFlow: Bool = true @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in var versionText = context.store.version(for: context.provider) ?? "not detected" if let parenRange = versionText.range(of: "(") { versionText = versionText[.. ProviderSettingsSnapshotContribution? { .claude(context.settings.claudeSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.claudeCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.claudeCookieSource != .manual { settings.claudeCookieSource = .manual } } @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { context.settings.claudeUsageDataSource.rawValue } @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { switch context.settings.claudeUsageDataSource { case .auto: .auto case .oauth: .oauth case .web: .web case .cli: .cli } } @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { let subtitle = if context.settings.debugDisableKeychainAccess { "Inactive while \"Disable Keychain access\" is enabled in Advanced." } else { "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." } let promptFreeBinding = Binding( get: { context.settings.claudeOAuthPromptFreeCredentialsEnabled }, set: { enabled in guard !context.settings.debugDisableKeychainAccess else { return } context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", title: "Avoid Keychain prompts (experimental)", subtitle: subtitle, binding: promptFreeBinding, statusText: nil, actions: [], isVisible: nil, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), ] } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( get: { context.settings.claudeUsageDataSource.rawValue }, set: { raw in context.settings.claudeUsageDataSource = ClaudeUsageDataSource(rawValue: raw) ?? .auto }) let cookieBinding = Binding( get: { context.settings.claudeCookieSource.rawValue }, set: { raw in context.settings.claudeCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let keychainPromptPolicyBinding = Binding( get: { context.settings.claudeOAuthKeychainPromptMode.rawValue }, set: { raw in context.settings.claudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptMode(rawValue: raw) ?? .onlyOnUserAction }) let usageOptions = ClaudeUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let keychainPromptPolicyOptions: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.never.rawValue, title: "Never prompt"), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue, title: "Only on user action"), ProviderSettingsPickerOption( id: ClaudeOAuthKeychainPromptMode.always.rawValue, title: "Always allow prompts"), ] let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.claudeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies for the web API.", manual: "Paste a Cookie header from a claude.ai request.", off: "Claude cookies are disabled.") } let keychainPromptPolicySubtitle: () -> String? = { if context.settings.debugDisableKeychainAccess { return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." } return "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing " + "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." } return [ ProviderSettingsPickerDescriptor( id: "claude-usage-source", title: "Usage source", subtitle: "Auto falls back to the next source if the preferred one fails.", binding: usageBinding, options: usageOptions, isVisible: nil, onChange: nil, trailingText: { guard context.settings.claudeUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .claude) return label == "auto" ? nil : label }), ProviderSettingsPickerDescriptor( id: "claude-keychain-prompt-policy", title: "Keychain prompt policy", subtitle: "Applies only to the Security.framework OAuth keychain reader.", dynamicSubtitle: keychainPromptPolicySubtitle, binding: keychainPromptPolicyBinding, options: keychainPromptPolicyOptions, isVisible: { context.settings.claudeOAuthKeychainReadStrategy == .securityFramework }, isEnabled: { !context.settings.debugDisableKeychainAccess }, onChange: nil), ProviderSettingsPickerDescriptor( id: "claude-cookie-source", title: "Claude cookies", subtitle: "Automatic imports browser cookies for the web API.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .claude) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { _ = context return [] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runClaudeLoginFlow() return true } @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { if context.snapshot?.secondary == nil { entries.append(.text("Weekly usage unavailable for this account.", .secondary)) } if let cost = context.snapshot?.providerCost, context.settings.showOptionalCreditsAndExtraUsage, cost.currencyCode != "Quota" { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) entries.append(.text("Extra usage: \(used) / \(limit)", .primary)) } } @MainActor func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil } return ("Open Terminal", .openTerminal(command: "claude")) } @MainActor private func shouldOpenTerminalForOAuthError(store: UsageStore) -> Bool { guard store.error(for: .claude) != nil else { return false } let attempts = store.fetchAttempts(for: .claude) if attempts.contains(where: { $0.kind == .oauth && ($0.errorDescription?.isEmpty == false) }) { return true } if let error = store.error(for: .claude)?.lowercased(), error.contains("oauth") { return true } return false } } ================================================ FILE: Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var claudeUsageDataSource: ClaudeUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .claude)?.source return Self.claudeUsageDataSource(from: source) } set { let source: ProviderSourceMode? = switch newValue { case .auto: .auto case .oauth: .oauth case .web: .web case .cli: .cli } self.updateProviderConfig(provider: .claude) { entry in entry.source = source } self.logProviderModeChange(provider: .claude, field: "usageSource", value: newValue.rawValue) if newValue != .cli { self.claudeWebExtrasEnabled = false } } } var claudeCookieHeader: String { get { self.configSnapshot.providerConfig(for: .claude)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .claude) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .claude, field: "cookieHeader", value: newValue) } } var claudeCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .claude, fallback: .auto) } set { self.updateProviderConfig(provider: .claude) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .claude, field: "cookieSource", value: newValue.rawValue) } } func ensureClaudeCookieLoaded() {} } extension SettingsStore { func claudeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .ClaudeProviderSettings { let account = self.selectedClaudeTokenAccount(tokenOverride: tokenOverride) let routing = self.claudeCredentialRouting(account: account) return ProviderSettingsSnapshot.ClaudeProviderSettings( usageDataSource: self.claudeUsageDataSource, webExtrasEnabled: self.claudeWebExtrasEnabled, cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing), manualCookieHeader: self.claudeSnapshotCookieHeader( routing: routing, hasSelectedAccount: account != nil)) } private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource { guard let source else { return .auto } switch source { case .auto, .api: return .auto case .web: return .web case .cli: return .cli case .oauth: return .oauth } } private func claudeSnapshotCookieHeader( routing: ClaudeCredentialRouting, hasSelectedAccount: Bool) -> String { switch routing { case .none: hasSelectedAccount ? "" : self.claudeCookieHeader case .oauth: "" case let .webCookie(header): header } } private func claudeSnapshotCookieSource( tokenOverride: TokenAccountOverride?, routing: ClaudeCredentialRouting) -> ProviderCookieSource { let fallback = self.claudeCookieSource guard let support = TokenAccountSupportCatalog.support(for: .claude), support.requiresManualCookieSource else { return fallback } if routing.isOAuth { return .off } if self.tokenAccounts(for: .claude).isEmpty { return fallback } return .manual } private func claudeCredentialRouting(account: ProviderTokenAccount?) -> ClaudeCredentialRouting { let manualCookieHeader = account == nil ? self.claudeCookieHeader : nil return ClaudeCredentialRouting.resolve( tokenAccountToken: account?.token, manualCookieHeader: manualCookieHeader) } private func selectedClaudeTokenAccount(tokenOverride: TokenAccountOverride?) -> ProviderTokenAccount? { ProviderTokenAccountSelection.selectedAccount( provider: .claude, settings: self, override: tokenOverride) } } ================================================ FILE: Sources/CodexBar/Providers/Codex/CodexLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runCodexLoginFlow() async { let result = await CodexLoginRunner.run(timeout: 120) guard !Task.isCancelled else { return } self.loginPhase = .idle self.presentCodexLoginResult(result) let outcome = self.describe(result.outcome) let length = result.output.count self.loginLogger.info("Codex login", metadata: ["outcome": outcome, "length": "\(length)"]) if case .success = result.outcome { self.postLoginNotification(for: .codex) } } } ================================================ FILE: Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct CodexProviderImplementation: ProviderImplementation { let id: UsageProvider = .codex let supportsLoginFlow: Bool = true @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in context.store.version(for: context.provider) ?? "not detected" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .codex(context.settings.codexSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { context.settings.codexUsageDataSource.rawValue } @MainActor func decorateSourceLabel(context: ProviderSourceLabelContext, baseLabel: String) -> String { if context.settings.codexCookieSource.isEnabled, context.store.openAIDashboard != nil, !context.store.openAIDashboardRequiresLogin, !baseLabel.contains("openai-web") { return "\(baseLabel) + openai-web" } return baseLabel } @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { switch context.settings.codexUsageDataSource { case .auto: .auto case .oauth: .oauth case .cli: .cli } } func makeRuntime() -> (any ProviderRuntime)? { CodexProviderRuntime() } @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { let extrasBinding = Binding( get: { context.settings.openAIWebAccessEnabled }, set: { enabled in context.settings.openAIWebAccessEnabled = enabled Task { @MainActor in await context.store.performRuntimeAction( .openAIWebAccessToggled(enabled), for: .codex) } }) return [ ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", subtitle: "Stores local Codex usage history (8 weeks) to personalize Pace predictions.", binding: context.boolBinding(\.historicalTrackingEnabled), statusText: nil, actions: [], isVisible: nil, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", title: "OpenAI web extras", subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", binding: extrasBinding, statusText: nil, actions: [], isVisible: nil, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), ] } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( get: { context.settings.codexUsageDataSource.rawValue }, set: { raw in context.settings.codexUsageDataSource = CodexUsageDataSource(rawValue: raw) ?? .auto }) let cookieBinding = Binding( get: { context.settings.codexCookieSource.rawValue }, set: { raw in context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let usageOptions = CodexUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } let cookieOptions = ProviderCookieSourceUI.options( allowsOff: true, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.codexCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies for dashboard extras.", manual: "Paste a Cookie header from a chatgpt.com request.", off: "Disable OpenAI dashboard cookie usage.") } return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", title: "Usage source", subtitle: "Auto falls back to the next source if the preferred one fails.", binding: usageBinding, options: usageOptions, isVisible: nil, onChange: nil, trailingText: { guard context.settings.codexUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .codex) return label == "auto" ? nil : label }), ProviderSettingsPickerDescriptor( id: "codex-cookie-source", title: "OpenAI cookies", subtitle: "Automatic imports browser cookies for dashboard extras.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: { context.settings.openAIWebAccessEnabled }, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "codex-cookie-header", title: "", subtitle: "", kind: .secure, placeholder: "Cookie: …", binding: context.stringBinding(\.codexCookieHeader), actions: [], isVisible: { context.settings.codexCookieSource == .manual }, onActivate: { context.settings.ensureCodexCookieLoaded() }), ] } @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard context.settings.showOptionalCreditsAndExtraUsage, context.metadata.supportsCredits else { return } if let credits = context.store.credits { entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) if let latest = credits.events.first { entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) } } else { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint entries.append(.text(hint, .secondary)) } } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runCodexLoginFlow() return true } } ================================================ FILE: Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift ================================================ import CodexBarCore import Foundation @MainActor final class CodexProviderRuntime: ProviderRuntime { let id: UsageProvider = .codex func perform(action: ProviderRuntimeAction, context: ProviderRuntimeContext) async { switch action { case let .openAIWebAccessToggled(enabled): guard enabled == false else { return } context.store.resetOpenAIWebState() case .forceSessionRefresh: break } } } ================================================ FILE: Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var codexUsageDataSource: CodexUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .codex)?.source return Self.codexUsageDataSource(from: source) } set { let source: ProviderSourceMode? = switch newValue { case .auto: .auto case .oauth: .oauth case .cli: .cli } self.updateProviderConfig(provider: .codex) { entry in entry.source = source } self.logProviderModeChange(provider: .codex, field: "usageSource", value: newValue.rawValue) } } var codexCookieHeader: String { get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .codex) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .codex, field: "cookieHeader", value: newValue) } } var codexCookieSource: ProviderCookieSource { get { let resolved = self.resolvedCookieSource(provider: .codex, fallback: .auto) return self.openAIWebAccessEnabled ? resolved : .off } set { self.updateProviderConfig(provider: .codex) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .codex, field: "cookieSource", value: newValue.rawValue) self.openAIWebAccessEnabled = newValue.isEnabled } } func ensureCodexCookieLoaded() {} } extension SettingsStore { func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings { ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride)) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { guard let source else { return .auto } switch source { case .auto, .web, .api: return .auto case .cli: return .cli case .oauth: return .oauth } } private func codexSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.codexCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .codex), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .codex, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func codexSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.codexCookieSource guard let support = TokenAccountSupportCatalog.support(for: .codex), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .codex).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift ================================================ import AppKit import CodexBarCore import SwiftUI @MainActor struct CopilotLoginFlow { static func run(settings: SettingsStore) async { let flow = CopilotDeviceFlow() do { let code = try await flow.requestDeviceCode() // Copy code to clipboard let pb = NSPasteboard.general pb.clearContents() pb.setString(code.userCode, forType: .string) let alert = NSAlert() alert.messageText = "GitHub Copilot Login" alert.informativeText = """ A device code has been copied to your clipboard: \(code.userCode) Please verify it at: \(code.verificationUri) """ alert.addButton(withTitle: "Open Browser") alert.addButton(withTitle: "Cancel") let response = alert.runModal() if response == .alertSecondButtonReturn { return // Cancelled } if let url = URL(string: code.verificationUri) { NSWorkspace.shared.open(url) } // Poll in background (modal blocks, but we need to wait for token effectively) // Ideally we'd show a "Waiting..." modal or spinner. // For simplicity, we can use a non-modal window or just block a Task? // `runModal` blocks the thread. We need to poll while the user is doing auth in browser. // But we already returned from runModal to open the browser. // We need a secondary "Waiting for confirmation..." alert or state. // Let's show a "Waiting" alert that can be cancelled. let waitingAlert = NSAlert() waitingAlert.messageText = "Waiting for Authentication..." waitingAlert.informativeText = """ Please complete the login in your browser. This window will close automatically when finished. """ waitingAlert.addButton(withTitle: "Cancel") let parentWindow = Self.resolveWaitingParentWindow() let hostWindow = parentWindow ?? Self.makeWaitingHostWindow() let shouldCloseHostWindow = parentWindow == nil let tokenTask = Task.detached(priority: .userInitiated) { try await flow.pollForToken(deviceCode: code.deviceCode, interval: code.interval) } let waitTask = Task { @MainActor in let response = await Self.presentWaitingAlert(waitingAlert, parentWindow: hostWindow) if response == .alertFirstButtonReturn { tokenTask.cancel() } return response } let tokenResult: Result do { let token = try await tokenTask.value tokenResult = .success(token) } catch { tokenResult = .failure(error) } Self.dismissWaitingAlert(waitingAlert, parentWindow: hostWindow, closeHost: shouldCloseHostWindow) let waitResponse = await waitTask.value if waitResponse == .alertFirstButtonReturn { return } switch tokenResult { case let .success(token): settings.copilotAPIToken = token settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() success.messageText = "Login Successful" success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } let err = NSAlert() err.messageText = "Login Failed" err.informativeText = error.localizedDescription err.runModal() } } catch { let err = NSAlert() err.messageText = "Login Failed" err.informativeText = error.localizedDescription err.runModal() } } @MainActor private static func presentWaitingAlert( _ alert: NSAlert, parentWindow: NSWindow) async -> NSApplication.ModalResponse { await withCheckedContinuation { continuation in alert.beginSheetModal(for: parentWindow) { response in continuation.resume(returning: response) } } } @MainActor private static func dismissWaitingAlert( _ alert: NSAlert, parentWindow: NSWindow, closeHost: Bool) { let alertWindow = alert.window if alertWindow.sheetParent != nil { parentWindow.endSheet(alertWindow) } else { alertWindow.orderOut(nil) } guard closeHost else { return } parentWindow.orderOut(nil) parentWindow.close() } @MainActor private static func resolveWaitingParentWindow() -> NSWindow? { if let window = NSApp.keyWindow ?? NSApp.mainWindow { return window } if let window = NSApp.windows.first(where: { $0.isVisible && !$0.ignoresMouseEvents }) { return window } return NSApp.windows.first } @MainActor private static func makeWaitingHostWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 1), styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.standardWindowButton(.closeButton)?.isHidden = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true window.backgroundColor = .clear window.isOpaque = false window.hasShadow = false window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.center() window.makeKeyAndOrderFront(nil) return window } } ================================================ FILE: Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import SwiftUI @ProviderImplementationRegistration struct CopilotProviderImplementation: ProviderImplementation { let id: UsageProvider = .copilot let supportsLoginFlow: Bool = true @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "github api" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.copilotAPIToken } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context return .copilot(context.settings.copilotSettingsSnapshot()) } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "copilot-api-token", title: "GitHub Login", subtitle: "Requires authentication via GitHub Device Flow.", kind: .secure, placeholder: "Sign in via button below", binding: context.stringBinding(\.copilotAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "copilot-login", title: "Sign in with GitHub", style: .bordered, isVisible: { context.settings.copilotAPIToken.isEmpty }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ProviderSettingsActionDescriptor( id: "copilot-relogin", title: "Sign in again", style: .link, isVisible: { !context.settings.copilotAPIToken.isEmpty }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ], isVisible: nil, onActivate: { context.settings.ensureCopilotAPITokenLoaded() }), ] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await CopilotLoginFlow.run(settings: context.controller.settings) return true } } ================================================ FILE: Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var copilotAPIToken: String { get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .copilot) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .copilot, field: "apiKey", value: newValue) } } func ensureCopilotAPITokenLoaded() {} } extension SettingsStore { func copilotSettingsSnapshot() -> ProviderSettingsSnapshot.CopilotProviderSettings { ProviderSettingsSnapshot.CopilotProviderSettings() } } ================================================ FILE: Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runCursorLoginFlow() async { let cursorRunner = CursorLoginRunner(browserDetection: self.store.browserDetection) let phaseHandler: @Sendable (CursorLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { case .loading, .waitingLogin: self?.loginPhase = .waitingBrowser case .success, .failed: self?.loginPhase = .idle } } } let result = await cursorRunner.run(onPhaseChange: phaseHandler) guard !Task.isCancelled else { return } self.loginPhase = .idle self.presentCursorLoginResult(result) let outcome = self.describe(result.outcome) self.loginLogger.info("Cursor login", metadata: ["outcome": outcome]) if case .success = result.outcome { self.postLoginNotification(for: .cursor) } } } ================================================ FILE: Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct CursorProviderImplementation: ProviderImplementation { let id: UsageProvider = .cursor let supportsLoginFlow: Bool = true @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "web" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.cursorCookieSource _ = settings.cursorCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .cursor(context.settings.cursorSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.cursorCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.cursorCookieSource != .manual { settings.cursorCookieSource = .manual } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.cursorCookieSource.rawValue }, set: { raw in context.settings.cursorCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.cursorCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies or stored sessions.", manual: "Paste a Cookie header from a cursor.com request.", off: "Cursor cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "cursor-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies or stored sessions.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .cursor) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { _ = context return [] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runCursorLoginFlow() return true } @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return } let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) if cost.limit > 0 { let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary)) } else { entries.append(.text("On-Demand: \(used)", .primary)) } } } ================================================ FILE: Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var cursorCookieHeader: String { get { self.configSnapshot.providerConfig(for: .cursor)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .cursor) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .cursor, field: "cookieHeader", value: newValue) } } var cursorCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .cursor, fallback: .auto) } set { self.updateProviderConfig(provider: .cursor) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .cursor, field: "cookieSource", value: newValue.rawValue) } } func ensureCursorCookieLoaded() {} } extension SettingsStore { func cursorSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .CursorProviderSettings { ProviderSettingsSnapshot.CursorProviderSettings( cookieSource: self.cursorSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.cursorSnapshotCookieHeader(tokenOverride: tokenOverride)) } private func cursorSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.cursorCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .cursor), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .cursor, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func cursorSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.cursorCookieSource guard let support = TokenAccountSupportCatalog.support(for: .cursor), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .cursor).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Factory/FactoryLoginFlow.swift ================================================ import AppKit import CodexBarCore @MainActor extension StatusItemController { func runFactoryLoginFlow() async { // Open Factory login page in default browser if let url = URL(string: "https://app.factory.ai") { NSWorkspace.shared.open(url) } } } ================================================ FILE: Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct FactoryProviderImplementation: ProviderImplementation { let id: UsageProvider = .factory let supportsLoginFlow: Bool = true @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.factoryCookieSource _ = settings.factoryCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .factory(context.settings.factorySettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.factoryCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.factoryCookieSource != .manual { settings.factoryCookieSource = .manual } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.factoryCookieSource.rawValue }, set: { raw in context.settings.factoryCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.factoryCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and WorkOS tokens.", manual: "Paste a Cookie header from app.factory.ai.", off: "Factory cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "factory-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies and WorkOS tokens.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .factory) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { _ = context return [] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runFactoryLoginFlow() return true } } ================================================ FILE: Sources/CodexBar/Providers/Factory/FactorySettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var factoryCookieHeader: String { get { self.configSnapshot.providerConfig(for: .factory)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .factory) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .factory, field: "cookieHeader", value: newValue) } } var factoryCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .factory, fallback: .auto) } set { self.updateProviderConfig(provider: .factory) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .factory, field: "cookieSource", value: newValue.rawValue) } } func ensureFactoryCookieLoaded() {} } extension SettingsStore { func factorySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .FactoryProviderSettings { ProviderSettingsSnapshot.FactoryProviderSettings( cookieSource: self.factorySnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.factorySnapshotCookieHeader(tokenOverride: tokenOverride)) } private func factorySnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.factoryCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .factory), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .factory, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func factorySnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.factoryCookieSource guard let support = TokenAccountSupportCatalog.support(for: .factory), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .factory).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runGeminiLoginFlow() async { let store = self.store let result = await GeminiLoginRunner.run { Task { @MainActor in await store.refresh() CodexBarLog.logger(LogCategories.login).info("Auto-refreshed after Gemini auth") } } guard !Task.isCancelled else { return } self.loginPhase = .idle self.presentGeminiLoginResult(result) let outcome = self.describe(result.outcome) self.loginLogger.info("Gemini login", metadata: ["outcome": outcome]) } } ================================================ FILE: Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct GeminiProviderImplementation: ProviderImplementation { let id: UsageProvider = .gemini let supportsLoginFlow: Bool = true @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runGeminiLoginFlow() return false } } ================================================ FILE: Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift ================================================ import CodexBarCore @MainActor extension StatusItemController { func runJetBrainsLoginFlow() async { self.loginPhase = .idle let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs(includeMissingQuota: true) if detectedIDEs.isEmpty { let message = [ "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar.", "Alternatively, set a custom path in Settings.", ].joined(separator: " ") self.presentLoginAlert( title: "No JetBrains IDE detected", message: message) } else { let ideNames = detectedIDEs.prefix(3).map(\.displayName).joined(separator: ", ") let hasQuotaFile = !JetBrainsIDEDetector.detectInstalledIDEs().isEmpty let message = hasQuotaFile ? "Detected: \(ideNames). Select your preferred IDE in Settings, then refresh CodexBar." : "Detected: \(ideNames). Use AI Assistant once to generate quota data, then refresh CodexBar." self.presentLoginAlert( title: "JetBrains AI is ready", message: message) } } } ================================================ FILE: Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct JetBrainsProviderImplementation: ProviderImplementation { let id: UsageProvider = .jetbrains @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context return .jetbrains(context.settings.jetbrainsSettingsSnapshot()) } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs(includeMissingQuota: true) guard !detectedIDEs.isEmpty else { return [] } var options: [ProviderSettingsPickerOption] = [ ProviderSettingsPickerOption(id: "", title: "Auto-detect"), ] for ide in detectedIDEs { options.append(ProviderSettingsPickerOption(id: ide.basePath, title: ide.displayName)) } return [ ProviderSettingsPickerDescriptor( id: "jetbrains.ide", title: "JetBrains IDE", subtitle: "Select the IDE to monitor", binding: context.stringBinding(\.jetbrainsIDEBasePath), options: options, isVisible: nil, onChange: nil, trailingText: { if context.settings.jetbrainsIDEBasePath.isEmpty { if let latest = JetBrainsIDEDetector.detectLatestIDE() { return latest.displayName } } return nil }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "jetbrains.customPath", title: "Custom Path", subtitle: "Override auto-detection with a custom IDE base path", kind: .plain, placeholder: "~/Library/Application Support/JetBrains/IntelliJIdea2024.3", binding: context.stringBinding(\.jetbrainsIDEBasePath), actions: [], isVisible: { let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs() return detectedIDEs.isEmpty || !context.settings.jetbrainsIDEBasePath.isEmpty }, onActivate: nil), ] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runJetBrainsLoginFlow() return false } } ================================================ FILE: Sources/CodexBar/Providers/JetBrains/JetBrainsSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { func jetbrainsSettingsSnapshot() -> ProviderSettingsSnapshot.JetBrainsProviderSettings { ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: self.jetbrainsIDEBasePath.isEmpty ? nil : self.jetbrainsIDEBasePath) } } ================================================ FILE: Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct KiloProviderImplementation: ProviderImplementation { let id: UsageProvider = .kilo @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.kiloUsageDataSource _ = settings.kiloExtrasEnabled _ = settings.kiloAPIToken } @MainActor func isAvailable(context _: ProviderAvailabilityContext) -> Bool { // Keep availability permissive to avoid main-thread auth-file I/O while still showing Kilo for auth.json-only // setups. Fetch-time auth resolution remains authoritative (env first, then auth file fallback). true } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .kilo(context.settings.kiloSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { context.settings.kiloUsageDataSource.rawValue } @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { switch context.settings.kiloUsageDataSource { case .auto: .auto case .api: .api case .cli: .cli } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( get: { context.settings.kiloUsageDataSource.rawValue }, set: { raw in context.settings.kiloUsageDataSource = KiloUsageDataSource(rawValue: raw) ?? .auto }) let usageOptions = KiloUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } return [ ProviderSettingsPickerDescriptor( id: "kilo-usage-source", title: "Usage source", subtitle: "Auto uses API first, then falls back to CLI on auth failures.", binding: usageBinding, options: usageOptions, isVisible: nil, onChange: nil, trailingText: { guard context.settings.kiloUsageDataSource == .auto else { return nil } let label = context.store.sourceLabel(for: .kilo) return label == "auto" ? nil : label }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "kilo-api-key", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " + "~/.local/share/kilo/auth.json (kilo.access).", kind: .secure, placeholder: "kilo_...", binding: context.stringBinding(\.kiloAPIToken), actions: [], isVisible: nil, onActivate: nil), ] } } ================================================ FILE: Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var kiloUsageDataSource: KiloUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .kilo)?.source return Self.kiloUsageDataSource(from: source) } set { let source: ProviderSourceMode? = switch newValue { case .auto: .auto case .api: .api case .cli: .cli } self.updateProviderConfig(provider: .kilo) { entry in entry.source = source } self.logProviderModeChange(provider: .kilo, field: "usageSource", value: newValue.rawValue) } } var kiloExtrasEnabled: Bool { get { guard self.kiloUsageDataSource == .auto else { return false } return self.kiloExtrasEnabledRaw } set { self.kiloExtrasEnabledRaw = newValue } } var kiloAPIToken: String { get { self.configSnapshot.providerConfig(for: .kilo)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .kilo) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .kilo, field: "apiKey", value: newValue) } } private var kiloExtrasEnabledRaw: Bool { get { self.configSnapshot.providerConfig(for: .kilo)?.extrasEnabled ?? false } set { self.updateProviderConfig(provider: .kilo) { entry in entry.extrasEnabled = newValue } self.logProviderModeChange( provider: .kilo, field: "extrasEnabled", value: newValue ? "1" : "0") } } } extension SettingsStore { func kiloSettingsSnapshot(tokenOverride _: TokenAccountOverride?) -> ProviderSettingsSnapshot.KiloProviderSettings { ProviderSettingsSnapshot.KiloProviderSettings( usageDataSource: self.kiloUsageDataSource, extrasEnabled: self.kiloExtrasEnabled) } private static func kiloUsageDataSource(from source: ProviderSourceMode?) -> KiloUsageDataSource { guard let source else { return .auto } switch source { case .auto, .web, .oauth: return .auto case .api: return .api case .cli: return .cli } } } ================================================ FILE: Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct KimiProviderImplementation: ProviderImplementation { let id: UsageProvider = .kimi @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "web" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.kimiCookieSource _ = settings.kimiManualCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .kimi(context.settings.kimiSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.kimiCookieSource.rawValue }, set: { raw in context.settings.kimiCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let options = ProviderCookieSourceUI.options( allowsOff: true, keychainDisabled: context.settings.debugDisableKeychainAccess) let subtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.kimiCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies.", manual: "Paste a cookie header or the kimi-auth token value.", off: "Kimi cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "kimi-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies.", dynamicSubtitle: subtitle, binding: cookieBinding, options: options, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "kimi-cookie", title: "", subtitle: "", kind: .secure, placeholder: "Cookie: \u{2026}\n\nor paste the kimi-auth token value", binding: context.stringBinding(\.kimiManualCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "kimi-open-console", title: "Open Console", style: .link, isVisible: nil, perform: { if let url = URL(string: "https://www.kimi.com/code/console") { NSWorkspace.shared.open(url) } }), ], isVisible: { context.settings.kimiCookieSource == .manual }, onActivate: { context.settings.ensureKimiAuthTokenLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var kimiManualCookieHeader: String { get { self.configSnapshot.providerConfig(for: .kimi)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .kimi) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .kimi, field: "cookieHeader", value: newValue) } } var kimiCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .kimi, fallback: .auto) } set { self.updateProviderConfig(provider: .kimi) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .kimi, field: "cookieSource", value: newValue.rawValue) } } func ensureKimiAuthTokenLoaded() {} } extension SettingsStore { func kimiSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.KimiProviderSettings { _ = tokenOverride self.ensureKimiAuthTokenLoaded() return ProviderSettingsSnapshot.KimiProviderSettings( cookieSource: self.kimiCookieSource, manualCookieHeader: self.kimiManualCookieHeader) } } ================================================ FILE: Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct KimiK2ProviderImplementation: ProviderImplementation { let id: UsageProvider = .kimik2 @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.kimiK2APIToken } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "kimi-k2-api-token", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai.", kind: .secure, placeholder: "Paste API key…", binding: context.stringBinding(\.kimiK2APIToken), actions: [ ProviderSettingsActionDescriptor( id: "kimi-k2-open-api-keys", title: "Open API Keys", style: .link, isVisible: nil, perform: { if let url = URL(string: "https://kimi-k2.ai/user-center/api-keys") { NSWorkspace.shared.open(url) } }), ], isVisible: nil, onActivate: { context.settings.ensureKimiK2APITokenLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/KimiK2/KimiK2SettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var kimiK2APIToken: String { get { self.configSnapshot.providerConfig(for: .kimik2)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .kimik2) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .kimik2, field: "apiKey", value: newValue) } } func ensureKimiK2APITokenLoaded() {} } ================================================ FILE: Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro } ================================================ FILE: Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct MiniMaxProviderImplementation: ProviderImplementation { let id: UsageProvider = .minimax @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in context.store.sourceLabel(for: context.provider) } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.minimaxCookieSource _ = settings.minimaxCookieHeader _ = settings.minimaxAPIToken _ = settings.minimaxAPIRegion } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .minimax(context.settings.minimaxSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } context.settings.ensureMiniMaxAPITokenLoaded() if context.settings.minimaxAuthMode().usesAPIToken { return false } return context.settings.minimaxCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.minimaxCookieSource != .manual { settings.minimaxCookieSource = .manual } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { context.settings.ensureMiniMaxAPITokenLoaded() let authMode: () -> MiniMaxAuthMode = { context.settings.minimaxAuthMode() } let cookieBinding = Binding( get: { context.settings.minimaxCookieSource.rawValue }, set: { raw in context.settings.minimaxCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and local storage tokens.", manual: "Paste a Cookie header or cURL capture from the Coding Plan page.", off: "MiniMax cookies are disabled.") } let regionBinding = Binding( get: { context.settings.minimaxAPIRegion.rawValue }, set: { raw in context.settings.minimaxAPIRegion = MiniMaxAPIRegion(rawValue: raw) ?? .global }) let regionOptions = MiniMaxAPIRegion.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } return [ ProviderSettingsPickerDescriptor( id: "minimax-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies and local storage tokens.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: { authMode().allowsCookies }, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .minimax) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ProviderSettingsPickerDescriptor( id: "minimax-region", title: "API region", subtitle: "Choose the MiniMax host (global .io or China mainland .com).", binding: regionBinding, options: regionOptions, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { context.settings.ensureMiniMaxAPITokenLoaded() let authMode: () -> MiniMaxAuthMode = { context.settings.minimaxAuthMode() } return [ ProviderSettingsFieldDescriptor( id: "minimax-api-token", title: "API token", subtitle: "Stored in ~/.codexbar/config.json. Paste your MiniMax API key.", kind: .secure, placeholder: "Paste API token…", binding: context.stringBinding(\.minimaxAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard", title: "Open Coding Plan", style: .link, isVisible: nil, perform: { NSWorkspace.shared.open(context.settings.minimaxAPIRegion.codingPlanURL) }), ], isVisible: nil, onActivate: { context.settings.ensureMiniMaxAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "minimax-cookie", title: "Cookie header", subtitle: "", kind: .secure, placeholder: "Cookie: …", binding: context.stringBinding(\.minimaxCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard-cookie", title: "Open Coding Plan", style: .link, isVisible: nil, perform: { NSWorkspace.shared.open(context.settings.minimaxAPIRegion.codingPlanURL) }), ], isVisible: { authMode().allowsCookies && context.settings.minimaxCookieSource == .manual }, onActivate: { context.settings.ensureMiniMaxCookieLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var minimaxAPIRegion: MiniMaxAPIRegion { get { let raw = self.configSnapshot.providerConfig(for: .minimax)?.region return MiniMaxAPIRegion(rawValue: raw ?? "") ?? .global } set { self.updateProviderConfig(provider: .minimax) { entry in entry.region = newValue.rawValue } } } var minimaxCookieHeader: String { get { self.configSnapshot.providerConfig(for: .minimax)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .minimax) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .minimax, field: "cookieHeader", value: newValue) } } var minimaxAPIToken: String { get { self.configSnapshot.providerConfig(for: .minimax)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .minimax) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .minimax, field: "apiKey", value: newValue) } } var minimaxCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .minimax, fallback: .auto) } set { self.updateProviderConfig(provider: .minimax) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .minimax, field: "cookieSource", value: newValue.rawValue) } } func ensureMiniMaxCookieLoaded() {} func ensureMiniMaxAPITokenLoaded() {} func minimaxAuthMode( environment: [String: String] = ProcessInfo.processInfo.environment) -> MiniMaxAuthMode { let apiToken = MiniMaxAPISettingsReader.apiToken(environment: environment) ?? self.minimaxAPIToken let cookieHeader = MiniMaxSettingsReader.cookieHeader(environment: environment) ?? self.minimaxCookieHeader return MiniMaxAuthMode.resolve(apiToken: apiToken, cookieHeader: cookieHeader) } } extension SettingsStore { func minimaxSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .MiniMaxProviderSettings { ProviderSettingsSnapshot.MiniMaxProviderSettings( cookieSource: self.minimaxSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.minimaxSnapshotCookieHeader(tokenOverride: tokenOverride), apiRegion: self.minimaxAPIRegion) } private func minimaxSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.minimaxCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .minimax), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .minimax, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func minimaxSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.minimaxCookieSource guard let support = TokenAccountSupportCatalog.support(for: .minimax), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .minimax).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct OllamaProviderImplementation: ProviderImplementation { let id: UsageProvider = .ollama @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.ollamaCookieSource _ = settings.ollamaCookieHeader } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .ollama(context.settings.ollamaSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.ollamaCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.ollamaCookieSource != .manual { settings.ollamaCookieSource = .manual } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.ollamaCookieSource.rawValue }, set: { raw in context.settings.ollamaCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.ollamaCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies.", manual: "Paste a Cookie header or cURL capture from Ollama settings.", off: "Ollama cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "ollama-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "ollama-cookie", title: "", subtitle: "", kind: .secure, placeholder: "Cookie: …", binding: context.stringBinding(\.ollamaCookieHeader), actions: [ ProviderSettingsActionDescriptor( id: "ollama-open-settings", title: "Open Ollama Settings", style: .link, isVisible: nil, perform: { if let url = URL(string: "https://ollama.com/settings") { NSWorkspace.shared.open(url) } }), ], isVisible: { context.settings.ollamaCookieSource == .manual }, onActivate: { context.settings.ensureOllamaCookieLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var ollamaCookieHeader: String { get { self.configSnapshot.providerConfig(for: .ollama)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .ollama) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .ollama, field: "cookieHeader", value: newValue) } } var ollamaCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .ollama, fallback: .auto) } set { self.updateProviderConfig(provider: .ollama) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .ollama, field: "cookieSource", value: newValue.rawValue) } } func ensureOllamaCookieLoaded() {} } extension SettingsStore { func ollamaSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .OllamaProviderSettings { ProviderSettingsSnapshot.OllamaProviderSettings( cookieSource: self.ollamaSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.ollamaSnapshotCookieHeader(tokenOverride: tokenOverride)) } private func ollamaSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.ollamaCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .ollama), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .ollama, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func ollamaSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.ollamaCookieSource guard let support = TokenAccountSupportCatalog.support(for: .ollama), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .ollama).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct OpenCodeProviderImplementation: ProviderImplementation { let id: UsageProvider = .opencode @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "web" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.opencodeCookieSource _ = settings.opencodeCookieHeader _ = settings.opencodeWorkspaceID } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .opencode(context.settings.opencodeSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } return context.settings.opencodeCookieSource == .manual } @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) { if settings.opencodeCookieSource != .manual { settings.opencodeCookieSource = .manual } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let cookieBinding = Binding( get: { context.settings.opencodeCookieSource.rawValue }, set: { raw in context.settings.opencodeCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.opencodeCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies from opencode.ai.", manual: "Paste a Cookie header captured from the billing page.", off: "OpenCode cookies are disabled.") } return [ ProviderSettingsPickerDescriptor( id: "opencode-cookie-source", title: "Cookie source", subtitle: "Automatic imports browser cookies from opencode.ai.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .opencode) else { return nil } let when = entry.storedAt.relativeDescription() return "Cached: \(entry.sourceLabel) • \(when)" }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "opencode-workspace-id", title: "Workspace ID", subtitle: "Optional override if workspace lookup fails.", kind: .plain, placeholder: "wrk_…", binding: context.stringBinding(\.opencodeWorkspaceID), actions: [], isVisible: nil, onActivate: nil), ] } } ================================================ FILE: Sources/CodexBar/Providers/OpenCode/OpenCodeSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var opencodeWorkspaceID: String { get { self.configSnapshot.providerConfig(for: .opencode)?.workspaceID ?? "" } set { let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let value = trimmed.isEmpty ? nil : trimmed self.updateProviderConfig(provider: .opencode) { entry in entry.workspaceID = value } } } var opencodeCookieHeader: String { get { self.configSnapshot.providerConfig(for: .opencode)?.sanitizedCookieHeader ?? "" } set { self.updateProviderConfig(provider: .opencode) { entry in entry.cookieHeader = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .opencode, field: "cookieHeader", value: newValue) } } var opencodeCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .opencode, fallback: .auto) } set { self.updateProviderConfig(provider: .opencode) { entry in entry.cookieSource = newValue } self.logProviderModeChange(provider: .opencode, field: "cookieSource", value: newValue.rawValue) } } func ensureOpenCodeCookieLoaded() {} } extension SettingsStore { func opencodeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .OpenCodeProviderSettings { ProviderSettingsSnapshot.OpenCodeProviderSettings( cookieSource: self.opencodeSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.opencodeSnapshotCookieHeader(tokenOverride: tokenOverride), workspaceID: self.opencodeWorkspaceID) } private func opencodeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { let fallback = self.opencodeCookieHeader guard let support = TokenAccountSupportCatalog.support(for: .opencode), case .cookieHeader = support.injection else { return fallback } guard let account = ProviderTokenAccountSelection.selectedAccount( provider: .opencode, settings: self, override: tokenOverride) else { return fallback } return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) } private func opencodeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { let fallback = self.opencodeCookieSource guard let support = TokenAccountSupportCatalog.support(for: .opencode), support.requiresManualCookieSource else { return fallback } if self.tokenAccounts(for: .opencode).isEmpty { return fallback } return .manual } } ================================================ FILE: Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct OpenRouterProviderImplementation: ProviderImplementation { let id: UsageProvider = .openrouter @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "api" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.openRouterAPIToken } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context return nil } @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { if OpenRouterSettingsReader.apiToken(environment: context.environment) != nil { return true } return !context.settings.openRouterAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @MainActor func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { [] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "openrouter-api-key", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. " + "Get your key from openrouter.ai/settings/keys and set a key spending limit " + "there to enable API key quota tracking.", kind: .secure, placeholder: "sk-or-v1-...", binding: context.stringBinding(\.openRouterAPIToken), actions: [], isVisible: nil, onActivate: nil), ] } } ================================================ FILE: Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var openRouterAPIToken: String { get { self.configSnapshot.providerConfig(for: .openrouter)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .openrouter) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .openrouter, field: "apiKey", value: newValue) } } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderCatalog.swift ================================================ import CodexBarCore /// Source of truth for app-side provider implementations. /// /// Keep provider registration centralized here. The rest of the app should *not* have to be updated when a new /// provider is added, aside from enum/metadata work in `CodexBarCore`. enum ProviderCatalog { /// All provider implementations shipped in the app. static let all: [any ProviderImplementation] = ProviderImplementationRegistry.all /// Lookup for a single provider implementation. static func implementation(for id: UsageProvider) -> (any ProviderImplementation)? { ProviderImplementationRegistry.implementation(for: id) } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderContext.swift ================================================ import CodexBarCore import Foundation struct ProviderPresentationContext { let provider: UsageProvider let settings: SettingsStore let store: UsageStore let metadata: ProviderMetadata } struct ProviderAvailabilityContext { let provider: UsageProvider let settings: SettingsStore let environment: [String: String] } struct ProviderSourceLabelContext { let provider: UsageProvider let settings: SettingsStore let store: UsageStore let descriptor: ProviderDescriptor } struct ProviderSourceModeContext { let provider: UsageProvider let settings: SettingsStore } struct ProviderVersionContext { let provider: UsageProvider let browserDetection: BrowserDetection } struct ProviderSettingsSnapshotContext { let settings: SettingsStore let tokenOverride: TokenAccountOverride? } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift ================================================ import CodexBarCore enum ProviderCookieSourceUI { static let keychainDisabledPrefix = "Keychain access is disabled in Advanced, so browser cookie import is unavailable." static func options(allowsOff: Bool, keychainDisabled: Bool) -> [ProviderSettingsPickerOption] { var options: [ProviderSettingsPickerOption] = [] if !keychainDisabled { options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.auto.rawValue, title: ProviderCookieSource.auto.displayName)) } options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.manual.rawValue, title: ProviderCookieSource.manual.displayName)) if allowsOff { options.append(ProviderSettingsPickerOption( id: ProviderCookieSource.off.rawValue, title: ProviderCookieSource.off.displayName)) } return options } static func subtitle( source: ProviderCookieSource, keychainDisabled: Bool, auto: String, manual: String, off: String) -> String { if keychainDisabled { return source == .off ? off : "\(self.keychainDisabledPrefix) \(manual)" } switch source { case .auto: return auto case .manual: return manual case .off: return off } } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderImplementation.swift ================================================ import CodexBarCore import Foundation /// App-side provider implementation. /// /// Rules: /// - Provider implementations return *data/behavior descriptors*; the app owns UI. /// - Do not mix identity fields across providers (email/org/plan/loginMethod stays siloed). protocol ProviderImplementation: Sendable { var id: UsageProvider { get } var supportsLoginFlow: Bool { get } @MainActor func presentation(context: ProviderPresentationContext) -> ProviderPresentation @MainActor func observeSettings(_ settings: SettingsStore) @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? @MainActor func decorateSourceLabel(context: ProviderSourceLabelContext, baseLabel: String) -> String @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode func detectVersion(context: ProviderVersionContext) async -> String? func makeRuntime() -> (any ProviderRuntime)? /// Optional provider-specific settings toggles to render in the Providers pane. /// /// Important: Providers must not return custom SwiftUI views here. Only shared toggle/action descriptors. @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] /// Optional provider-specific settings fields to render in the Providers pane. @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] /// Optional provider-specific settings pickers to render in the Providers pane. @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] /// Optional visibility gate for token account settings. @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool /// Optional provider-specific settings snapshot contribution. @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? /// Optional hook to update provider settings when token accounts change. @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) /// Optional provider-specific menu entries for the usage section. @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) /// Optional provider-specific menu entries for the actions section. @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) /// Optional override for the login/switch account menu action. @MainActor func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? /// Optional provider-specific login flow. Returns whether to refresh after completion. @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool } extension ProviderImplementation { var supportsLoginFlow: Bool { false } @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation(detailLine: ProviderPresentation.standardDetailLine) } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings } @MainActor func isAvailable(context _: ProviderAvailabilityContext) -> Bool { true } @MainActor func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { nil } @MainActor func decorateSourceLabel(context _: ProviderSourceLabelContext, baseLabel: String) -> String { baseLabel } @MainActor func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { .auto } func detectVersion(context: ProviderVersionContext) async -> String? { let detector = ProviderDescriptorRegistry.descriptor(for: self.id).cli.versionDetector return detector?(context.browserDetection) } func makeRuntime() -> (any ProviderRuntime)? { nil } @MainActor func settingsToggles(context _: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { [] } @MainActor func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [] } @MainActor func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { [] } @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } return !context.settings.tokenAccounts(for: context.provider).isEmpty } @MainActor func settingsSnapshot(context _: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { nil } @MainActor func applyTokenAccountCookieSource(settings _: SettingsStore) {} @MainActor func appendUsageMenuEntries(context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} @MainActor func appendActionMenuEntries(context _: ProviderMenuActionContext, entries _: inout [ProviderMenuEntry]) {} @MainActor func loginMenuAction(context _: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { nil } @MainActor func runLoginFlow(context _: ProviderLoginContext) async -> Bool { false } } struct ProviderLoginContext { unowned let controller: StatusItemController } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift ================================================ import CodexBarCore import Foundation enum ProviderImplementationRegistry { private final class Store: @unchecked Sendable { var ordered: [any ProviderImplementation] = [] var byID: [UsageProvider: any ProviderImplementation] = [:] } private static let lock = NSLock() private static let store = Store() // swiftlint:disable:next cyclomatic_complexity private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) { switch provider { case .codex: CodexProviderImplementation() case .claude: ClaudeProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() case .alibaba: AlibabaCodingPlanProviderImplementation() case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() case .copilot: CopilotProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() case .kimi: KimiProviderImplementation() case .kilo: KiloProviderImplementation() case .kiro: KiroProviderImplementation() case .vertexai: VertexAIProviderImplementation() case .augment: AugmentProviderImplementation() case .jetbrains: JetBrainsProviderImplementation() case .kimik2: KimiK2ProviderImplementation() case .amp: AmpProviderImplementation() case .ollama: OllamaProviderImplementation() case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() } } private static let bootstrap: Void = { for provider in UsageProvider.allCases { _ = ProviderImplementationRegistry.register(makeImplementation(for: provider)) } }() private static func ensureBootstrapped() { _ = self.bootstrap } @discardableResult static func register(_ implementation: any ProviderImplementation) -> any ProviderImplementation { self.lock.lock() defer { self.lock.unlock() } if self.store.byID[implementation.id] == nil { self.store.ordered.append(implementation) } self.store.byID[implementation.id] = implementation return implementation } static var all: [any ProviderImplementation] { self.ensureBootstrapped() self.lock.lock() defer { self.lock.unlock() } return self.store.ordered } static func implementation(for id: UsageProvider) -> (any ProviderImplementation)? { self.ensureBootstrapped() if let found = self.store.byID[id] { return found } return self.all.first(where: { $0.id == id }) } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderLoginFlow.swift ================================================ import AppKit import CodexBarCore @MainActor extension StatusItemController { /// Runs the provider-specific login flow. /// - Returns: Whether CodexBar should refresh after the flow completes. func runLoginFlow(provider: UsageProvider) async -> Bool { guard let impl = ProviderCatalog.implementation(for: provider) else { return false } return await impl.runLoginFlow(context: ProviderLoginContext(controller: self)) } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift ================================================ import CodexBarCore import Foundation typealias ProviderMenuEntry = MenuDescriptor.Entry struct ProviderMenuUsageContext { let provider: UsageProvider let store: UsageStore let settings: SettingsStore let metadata: ProviderMetadata let snapshot: UsageSnapshot? } struct ProviderMenuActionContext { let provider: UsageProvider let store: UsageStore let settings: SettingsStore let account: AccountInfo } struct ProviderMenuLoginContext { let provider: UsageProvider let store: UsageStore let settings: SettingsStore let account: AccountInfo } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderPresentation.swift ================================================ import CodexBarCore import Foundation struct ProviderPresentation { let detailLine: @MainActor (ProviderPresentationContext) -> String @MainActor static func standardDetailLine(context: ProviderPresentationContext) -> String { let versionText = context.store.version(for: context.provider) ?? "not detected" return "\(context.metadata.cliName) \(versionText)" } } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderRuntime.swift ================================================ import CodexBarCore import Foundation struct ProviderRuntimeContext { let provider: UsageProvider let settings: SettingsStore let store: UsageStore } enum ProviderRuntimeAction { case forceSessionRefresh case openAIWebAccessToggled(Bool) } @MainActor protocol ProviderRuntime: AnyObject { var id: UsageProvider { get } func start(context: ProviderRuntimeContext) func stop(context: ProviderRuntimeContext) func settingsDidChange(context: ProviderRuntimeContext) func providerDidRefresh(context: ProviderRuntimeContext, provider: UsageProvider) func providerDidFail(context: ProviderRuntimeContext, provider: UsageProvider, error: Error) func perform(action: ProviderRuntimeAction, context: ProviderRuntimeContext) async } extension ProviderRuntime { func start(context _: ProviderRuntimeContext) {} func stop(context _: ProviderRuntimeContext) {} func settingsDidChange(context _: ProviderRuntimeContext) {} func providerDidRefresh(context _: ProviderRuntimeContext, provider _: UsageProvider) {} func providerDidFail(context _: ProviderRuntimeContext, provider _: UsageProvider, error _: Error) {} func perform(action _: ProviderRuntimeAction, context _: ProviderRuntimeContext) async {} } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift ================================================ import CodexBarCore import Foundation import SwiftUI /// Settings UI context passed to provider implementations. /// /// Providers use this to: /// - bind to `SettingsStore` values /// - read current provider state from `UsageStore` /// - surface transient status text (e.g. "Importing cookies…") /// - request a shared confirmation alert (no provider-specific UI) @MainActor struct ProviderSettingsContext { let provider: UsageProvider let settings: SettingsStore let store: UsageStore let boolBinding: (ReferenceWritableKeyPath) -> Binding let stringBinding: (ReferenceWritableKeyPath) -> Binding let statusText: (String) -> String? let setStatusText: (String, String?) -> Void let lastAppActiveRunAt: (String) -> Date? let setLastAppActiveRunAt: (String, Date?) -> Void let requestConfirmation: (ProviderSettingsConfirmation) -> Void } /// Shared confirmation alert descriptor. /// /// Providers can request confirmations (e.g. permission prompts) without supplying custom UI. @MainActor struct ProviderSettingsConfirmation { let title: String let message: String let confirmTitle: String let onConfirm: () -> Void } /// Shared toggle descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsToggleDescriptor: Identifiable { let id: String let title: String let subtitle: String let binding: Binding /// Optional short status text shown under the toggle when enabled. let statusText: (() -> String?)? /// Optional actions shown under the toggle when enabled. let actions: [ProviderSettingsActionDescriptor] /// Optional runtime visibility gate. let isVisible: (() -> Bool)? /// Called whenever the toggle changes. let onChange: ((_ enabled: Bool) async -> Void)? /// Called when the app becomes active (used for "retry after permission grant" flows). let onAppDidBecomeActive: (() async -> Void)? /// Called when the view appears while the toggle is enabled. let onAppearWhenEnabled: (() async -> Void)? } /// Shared text field descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsFieldDescriptor: Identifiable { enum Kind { case plain case secure } let id: String let title: String let subtitle: String let kind: Kind let placeholder: String? let binding: Binding let actions: [ProviderSettingsActionDescriptor] let isVisible: (() -> Bool)? let onActivate: (() -> Void)? } /// Shared token account descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let id: String let title: String let subtitle: String let placeholder: String let provider: UsageProvider let isVisible: (() -> Bool)? let accounts: () -> [ProviderTokenAccount] let activeIndex: () -> Int let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void let openConfigFile: () -> Void let reloadFromDisk: () -> Void } /// Shared picker descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsPickerDescriptor: Identifiable { let id: String let title: String let subtitle: String let dynamicSubtitle: (() -> String?)? let binding: Binding let options: [ProviderSettingsPickerOption] let isVisible: (() -> Bool)? let isEnabled: (() -> Bool)? let onChange: ((_ selection: String) async -> Void)? let trailingText: (() -> String?)? init( id: String, title: String, subtitle: String, dynamicSubtitle: (() -> String?)? = nil, binding: Binding, options: [ProviderSettingsPickerOption], isVisible: (() -> Bool)?, isEnabled: (() -> Bool)? = nil, onChange: ((_ selection: String) async -> Void)?, trailingText: (() -> String?)? = nil) { self.id = id self.title = title self.subtitle = subtitle self.dynamicSubtitle = dynamicSubtitle self.binding = binding self.options = options self.isVisible = isVisible self.isEnabled = isEnabled self.onChange = onChange self.trailingText = trailingText } } struct ProviderSettingsPickerOption: Identifiable { let id: String let title: String } /// Shared action descriptor rendered under a settings toggle. @MainActor struct ProviderSettingsActionDescriptor: Identifiable { enum Style { case bordered case link } let id: String let title: String let style: Style let isVisible: (() -> Bool)? let perform: () async -> Void } ================================================ FILE: Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift ================================================ import CodexBarCore import Foundation struct TokenAccountOverride { let provider: UsageProvider let account: ProviderTokenAccount } enum ProviderTokenAccountSelection { @MainActor static func selectedAccount( provider: UsageProvider, settings: SettingsStore, override: TokenAccountOverride?) -> ProviderTokenAccount? { if let override, override.provider == provider { return override.account } return settings.selectedTokenAccount(for: provider) } } ================================================ FILE: Sources/CodexBar/Providers/Shared/SystemSettingsLinks.swift ================================================ import AppKit import Foundation enum SystemSettingsLinks { /// Opens System Settings → Privacy & Security → Full Disk Access (best effort). static func openFullDiskAccess() { // Best-effort deep link. On older betas it sometimes opened the wrong pane; on modern macOS this is stable. let urls: [URL] = [ URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"), URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy"), URL(string: "x-apple.systempreferences:com.apple.preference.security"), ].compactMap(\.self) for url in urls where NSWorkspace.shared.open(url) { return } } } ================================================ FILE: Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct SyntheticProviderImplementation: ProviderImplementation { let id: UsageProvider = .synthetic @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "api" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.syntheticAPIToken } @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { if SyntheticSettingsReader.apiKey(environment: context.environment) != nil { return true } context.settings.ensureSyntheticAPITokenLoaded() return !context.settings.syntheticAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "synthetic-api-key", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard.", kind: .secure, placeholder: "Paste key…", binding: context.stringBinding(\.syntheticAPIToken), actions: [], isVisible: nil, onActivate: { context.settings.ensureSyntheticAPITokenLoaded() }), ] } } ================================================ FILE: Sources/CodexBar/Providers/Synthetic/SyntheticSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var syntheticAPIToken: String { get { self.configSnapshot.providerConfig(for: .synthetic)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .synthetic) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .synthetic, field: "apiKey", value: newValue) } } func ensureSyntheticAPITokenLoaded() {} } ================================================ FILE: Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift ================================================ import AppKit import CodexBarCore import Foundation @MainActor extension StatusItemController { func runVertexAILoginFlow() async { // Show alert with instructions let alert = NSAlert() alert.messageText = "Vertex AI Login" alert.informativeText = """ To use Vertex AI tracking, you need to authenticate with Google Cloud. 1. Open Terminal 2. Run: gcloud auth application-default login 3. Follow the browser prompts to sign in 4. Set your project: gcloud config set project PROJECT_ID Would you like to open Terminal now? """ alert.alertStyle = .informational alert.addButton(withTitle: "Open Terminal") alert.addButton(withTitle: "Cancel") let response = alert.runModal() if response == .alertFirstButtonReturn { Self.openTerminalWithGcloudCommand() } // Refresh after user may have logged in self.loginPhase = .idle Task { @MainActor in try? await Task.sleep(for: .seconds(2)) await self.store.refresh() } } private static func openTerminalWithGcloudCommand() { let script = """ tell application "Terminal" activate do script "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform" end tell """ if let appleScript = NSAppleScript(source: script) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { CodexBarLog.logger(LogCategories.terminal).error( "Failed to open Terminal", metadata: ["error": String(describing: error)]) } } } } ================================================ FILE: Sources/CodexBar/Providers/VertexAI/VertexAIProviderImplementation.swift ================================================ import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct VertexAIProviderImplementation: ProviderImplementation { let id: UsageProvider = .vertexai let supportsLoginFlow: Bool = true @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runVertexAILoginFlow() return false } } ================================================ FILE: Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation @ProviderImplementationRegistration struct WarpProviderImplementation: ProviderImplementation { let id: UsageProvider = .warp @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.warpAPIToken } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( id: "warp-api-token", title: "API key", subtitle: "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, " + "then create one.", kind: .secure, placeholder: "wk-...", binding: context.stringBinding(\.warpAPIToken), actions: [ ProviderSettingsActionDescriptor( id: "warp-open-api-keys", title: "Open Warp API Key Guide", style: .link, isVisible: nil, perform: { if let url = URL(string: "https://docs.warp.dev/reference/cli/api-keys") { NSWorkspace.shared.open(url) } }), ], isVisible: nil, onActivate: nil), ] } } ================================================ FILE: Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var warpAPIToken: String { get { self.configSnapshot.providerConfig(for: .warp)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .warp) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .warp, field: "apiKey", value: newValue) } } } ================================================ FILE: Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift ================================================ import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation import SwiftUI @ProviderImplementationRegistration struct ZaiProviderImplementation: ProviderImplementation { let id: UsageProvider = .zai @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { _ in "api" } } @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.zaiAPIToken _ = settings.zaiAPIRegion } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context return .zai(context.settings.zaiSettingsSnapshot()) } @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { if ZaiSettingsReader.apiToken(environment: context.environment) != nil { return true } context.settings.ensureZaiAPITokenLoaded() return !context.settings.zaiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let binding = Binding( get: { context.settings.zaiAPIRegion.rawValue }, set: { raw in context.settings.zaiAPIRegion = ZaiAPIRegion(rawValue: raw) ?? .global }) let options = ZaiAPIRegion.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } return [ ProviderSettingsPickerDescriptor( id: "zai-api-region", title: "API region", subtitle: "Use BigModel for the China mainland endpoints (open.bigmodel.cn).", binding: binding, options: options, isVisible: nil, onChange: nil), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { _ = context return [] } } ================================================ FILE: Sources/CodexBar/Providers/Zai/ZaiSettingsStore.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { var zaiAPIRegion: ZaiAPIRegion { get { let raw = self.configSnapshot.providerConfig(for: .zai)?.region return ZaiAPIRegion(rawValue: raw ?? "") ?? .global } set { self.updateProviderConfig(provider: .zai) { entry in entry.region = newValue.rawValue } } } var zaiAPIToken: String { get { self.configSnapshot.providerConfig(for: .zai)?.sanitizedAPIKey ?? "" } set { self.updateProviderConfig(provider: .zai) { entry in entry.apiKey = self.normalizedConfigValue(newValue) } self.logSecretUpdate(provider: .zai, field: "apiKey", value: newValue) } } func ensureZaiAPITokenLoaded() {} } extension SettingsStore { func zaiSettingsSnapshot() -> ProviderSettingsSnapshot.ZaiProviderSettings { ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.zaiAPIRegion) } } ================================================ FILE: Sources/CodexBar/SessionQuotaNotifications.swift ================================================ import CodexBarCore import Foundation @preconcurrency import UserNotifications enum SessionQuotaTransition: Equatable { case none case depleted case restored } enum SessionQuotaNotificationLogic { static let depletedThreshold: Double = 0.0001 static func isDepleted(_ remaining: Double?) -> Bool { guard let remaining else { return false } return remaining <= Self.depletedThreshold } static func transition(previousRemaining: Double?, currentRemaining: Double?) -> SessionQuotaTransition { guard let currentRemaining else { return .none } guard let previousRemaining else { return .none } let wasDepleted = previousRemaining <= Self.depletedThreshold let isDepleted = currentRemaining <= Self.depletedThreshold if !wasDepleted, isDepleted { return .depleted } if wasDepleted, !isDepleted { return .restored } return .none } } @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) } @MainActor final class SessionQuotaNotifier: SessionQuotaNotifying { private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) init() {} func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber? = nil) { guard transition != .none else { return } let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let (title, body) = switch transition { case .none: ("", "") case .depleted: ("\(providerName) session depleted", "0% left. Will notify when it's available again.") case .restored: ("\(providerName) session restored", "Session quota is available again.") } let providerText = provider.rawValue let transitionText = String(describing: transition) let idPrefix = "session-\(providerText)-\(transitionText)" self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } } ================================================ FILE: Sources/CodexBar/SettingsStore+Config.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { func providerConfig(for provider: UsageProvider) -> ProviderConfig? { self.configSnapshot.providerConfig(for: provider) } var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { get { Dictionary(uniqueKeysWithValues: self.configSnapshot.providers.compactMap { entry in guard let accounts = entry.tokenAccounts else { return nil } return (entry.id, accounts) }) } set { self.updateProviderTokenAccounts(newValue) } } } extension SettingsStore { func resolvedCookieSource( provider: UsageProvider, fallback: ProviderCookieSource) -> ProviderCookieSource { let source = self.configSnapshot.providerConfig(for: provider)?.cookieSource ?? fallback guard self.debugDisableKeychainAccess == false else { return source == .off ? .off : .manual } return source } func logProviderModeChange(provider: UsageProvider, field: String, value: String) { CodexBarLog.logger(LogCategories.settings).info( "Provider mode updated", metadata: ["provider": provider.rawValue, "field": field, "value": value]) } func logSecretUpdate(provider: UsageProvider, field: String, value: String) { var metadata = LogMetadata.secretSummary(value) metadata["provider"] = provider.rawValue metadata["field"] = field CodexBarLog.logger(LogCategories.settings).info( "Provider secret updated", metadata: metadata) } } ================================================ FILE: Sources/CodexBar/SettingsStore+ConfigPersistence.swift ================================================ import CodexBarCore import Foundation private enum ConfigChangeOrigin { case localUser case externalSync case reload } private struct ConfigChangeContext { let origin: ConfigChangeOrigin let reason: String static func local(reason: String) -> Self { Self(origin: .localUser, reason: reason) } static func external(reason: String) -> Self { Self(origin: .externalSync, reason: reason) } static func reload(reason: String) -> Self { Self(origin: .reload, reason: reason) } var shouldBroadcast: Bool { switch self.origin { case .localUser: true case .externalSync, .reload: false } } } extension SettingsStore { private func updateConfig(reason: String, mutate: (inout CodexBarConfig) -> Void) { guard !self.configLoading else { return } var config = self.config mutate(&config) self.config = config.normalized() self.updateProviderState(config: self.config) self.schedulePersistConfig() self.bumpConfigRevision(.local(reason: reason)) } func updateProviderConfig(provider: UsageProvider, mutate: (inout ProviderConfig) -> Void) { self.updateConfig(reason: "provider-\(provider.rawValue)") { config in if let index = config.providers.firstIndex(where: { $0.id == provider }) { var entry = config.providers[index] mutate(&entry) config.providers[index] = entry } else { var entry = ProviderConfig(id: provider) mutate(&entry) config.providers.append(entry) } } } func updateProviderTokenAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) { let summary = accounts .sorted { $0.key.rawValue < $1.key.rawValue } .map { "\($0.key.rawValue)=\($0.value.accounts.count)" } .joined(separator: ",") CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token accounts updated", metadata: [ "providers": "\(accounts.count)", "summary": summary, ]) self.updateConfig(reason: "token-accounts") { config in var seen: Set = [] for index in config.providers.indices { let provider = config.providers[index].id config.providers[index].tokenAccounts = accounts[provider] seen.insert(provider) } for (provider, data) in accounts where !seen.contains(provider) { config.providers.append(ProviderConfig(id: provider, tokenAccounts: data)) } } } func setProviderOrder(_ order: [UsageProvider]) { self.updateConfig(reason: "order") { config in let configsByID = Dictionary(uniqueKeysWithValues: config.providers.map { ($0.id, $0) }) var seen: Set = [] var ordered: [ProviderConfig] = [] ordered.reserveCapacity(max(order.count, config.providers.count)) for provider in order { guard !seen.contains(provider) else { continue } seen.insert(provider) ordered.append(configsByID[provider] ?? ProviderConfig(id: provider)) } for provider in UsageProvider.allCases where !seen.contains(provider) { ordered.append(configsByID[provider] ?? ProviderConfig(id: provider)) } config.providers = ordered } } func reloadConfig(reason: String) { guard !self.configLoading else { return } do { guard let loaded = try self.configStore.load() else { return } self.applyExternalConfig(loaded, reason: "reload-\(reason)") } catch { CodexBarLog.logger(LogCategories.configStore).error("Failed to reload config: \(error)") } } func applyExternalConfig(_ config: CodexBarConfig, reason: String) { guard !self.configLoading else { return } self.configLoading = true self.config = config self.updateProviderState(config: config) self.configLoading = false self.bumpConfigRevision(.external(reason: "sync-\(reason)")) } private func bumpConfigRevision(_ context: ConfigChangeContext) { self.configRevision &+= 1 CodexBarLog.logger(LogCategories.settings) .debug("Config revision bumped (\(context.reason)) -> \(self.configRevision)") guard context.shouldBroadcast else { return } NotificationCenter.default.post( name: .codexbarProviderConfigDidChange, object: self, userInfo: [ "config": self.config, "reason": context.reason, "revision": self.configRevision, ]) } func normalizedConfigValue(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } func schedulePersistConfig() { guard !self.configLoading else { return } self.configPersistTask?.cancel() if Self.isRunningTests { do { try self.configStore.save(self.config) } catch { CodexBarLog.logger(LogCategories.configStore).error("Failed to persist config: \(error)") } return } let store = self.configStore self.configPersistTask = Task { @MainActor in do { try await Task.sleep(nanoseconds: 350_000_000) } catch { return } guard !Task.isCancelled else { return } let snapshot = self.config let error: (any Error)? = await Task.detached(priority: .utility) { do { try store.save(snapshot) return nil } catch { return error } }.value if let error { CodexBarLog.logger(LogCategories.configStore).error("Failed to persist config: \(error)") } } } } ================================================ FILE: Sources/CodexBar/SettingsStore+Defaults.swift ================================================ import CodexBarCore import Foundation import ServiceManagement extension SettingsStore { private static let mergedOverviewSelectionEditedActiveProvidersKey = "mergedOverviewSelectionEditedActiveProviders" var refreshFrequency: RefreshFrequency { get { self.defaultsState.refreshFrequency } set { self.defaultsState.refreshFrequency = newValue self.userDefaults.set(newValue.rawValue, forKey: "refreshFrequency") } } var launchAtLogin: Bool { get { self.defaultsState.launchAtLogin } set { self.defaultsState.launchAtLogin = newValue self.userDefaults.set(newValue, forKey: "launchAtLogin") LaunchAtLoginManager.setEnabled(newValue) } } var debugMenuEnabled: Bool { get { self.defaultsState.debugMenuEnabled } set { self.defaultsState.debugMenuEnabled = newValue self.userDefaults.set(newValue, forKey: "debugMenuEnabled") } } var debugDisableKeychainAccess: Bool { get { self.defaultsState.debugDisableKeychainAccess } set { self.defaultsState.debugDisableKeychainAccess = newValue self.userDefaults.set(newValue, forKey: "debugDisableKeychainAccess") Self.sharedDefaults?.set(newValue, forKey: "debugDisableKeychainAccess") KeychainAccessGate.isDisabled = newValue } } var debugFileLoggingEnabled: Bool { get { self.defaultsState.debugFileLoggingEnabled } set { self.defaultsState.debugFileLoggingEnabled = newValue self.userDefaults.set(newValue, forKey: "debugFileLoggingEnabled") CodexBarLog.setFileLoggingEnabled(newValue) } } var debugLogLevel: CodexBarLog.Level { get { let raw = self.defaultsState.debugLogLevelRaw return CodexBarLog.parseLevel(raw) ?? .verbose } set { self.defaultsState.debugLogLevelRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "debugLogLevel") CodexBarLog.setLogLevel(newValue) } } var debugKeepCLISessionsAlive: Bool { get { self.defaultsState.debugKeepCLISessionsAlive } set { self.defaultsState.debugKeepCLISessionsAlive = newValue self.userDefaults.set(newValue, forKey: "debugKeepCLISessionsAlive") } } var isVerboseLoggingEnabled: Bool { self.debugLogLevel.rank <= CodexBarLog.Level.verbose.rank } private var debugLoadingPatternRaw: String? { get { self.defaultsState.debugLoadingPatternRaw } set { self.defaultsState.debugLoadingPatternRaw = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "debugLoadingPattern") } else { self.userDefaults.removeObject(forKey: "debugLoadingPattern") } } } var statusChecksEnabled: Bool { get { self.defaultsState.statusChecksEnabled } set { self.defaultsState.statusChecksEnabled = newValue self.userDefaults.set(newValue, forKey: "statusChecksEnabled") } } var sessionQuotaNotificationsEnabled: Bool { get { self.defaultsState.sessionQuotaNotificationsEnabled } set { self.defaultsState.sessionQuotaNotificationsEnabled = newValue self.userDefaults.set(newValue, forKey: "sessionQuotaNotificationsEnabled") } } var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { self.defaultsState.usageBarsShowUsed = newValue self.userDefaults.set(newValue, forKey: "usageBarsShowUsed") } } var resetTimesShowAbsolute: Bool { get { self.defaultsState.resetTimesShowAbsolute } set { self.defaultsState.resetTimesShowAbsolute = newValue self.userDefaults.set(newValue, forKey: "resetTimesShowAbsolute") } } var menuBarShowsBrandIconWithPercent: Bool { get { self.defaultsState.menuBarShowsBrandIconWithPercent } set { self.defaultsState.menuBarShowsBrandIconWithPercent = newValue self.userDefaults.set(newValue, forKey: "menuBarShowsBrandIconWithPercent") } } private var menuBarDisplayModeRaw: String? { get { self.defaultsState.menuBarDisplayModeRaw } set { self.defaultsState.menuBarDisplayModeRaw = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "menuBarDisplayMode") } else { self.userDefaults.removeObject(forKey: "menuBarDisplayMode") } } } var menuBarDisplayMode: MenuBarDisplayMode { get { MenuBarDisplayMode(rawValue: self.menuBarDisplayModeRaw ?? "") ?? .percent } set { self.menuBarDisplayModeRaw = newValue.rawValue } } var showAllTokenAccountsInMenu: Bool { get { self.defaultsState.showAllTokenAccountsInMenu } set { self.defaultsState.showAllTokenAccountsInMenu = newValue self.userDefaults.set(newValue, forKey: "showAllTokenAccountsInMenu") } } var historicalTrackingEnabled: Bool { get { self.defaultsState.historicalTrackingEnabled } set { self.defaultsState.historicalTrackingEnabled = newValue self.userDefaults.set(newValue, forKey: "historicalTrackingEnabled") } } var menuBarMetricPreferencesRaw: [String: String] { get { self.defaultsState.menuBarMetricPreferencesRaw } set { self.defaultsState.menuBarMetricPreferencesRaw = newValue self.userDefaults.set(newValue, forKey: "menuBarMetricPreferences") } } var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { self.defaultsState.costUsageEnabled = newValue self.userDefaults.set(newValue, forKey: "tokenCostUsageEnabled") } } var hidePersonalInfo: Bool { get { self.defaultsState.hidePersonalInfo } set { self.defaultsState.hidePersonalInfo = newValue self.userDefaults.set(newValue, forKey: "hidePersonalInfo") } } var randomBlinkEnabled: Bool { get { self.defaultsState.randomBlinkEnabled } set { self.defaultsState.randomBlinkEnabled = newValue self.userDefaults.set(newValue, forKey: "randomBlinkEnabled") } } var menuBarShowsHighestUsage: Bool { get { self.defaultsState.menuBarShowsHighestUsage } set { self.defaultsState.menuBarShowsHighestUsage = newValue self.userDefaults.set(newValue, forKey: "menuBarShowsHighestUsage") } } var claudeOAuthKeychainPromptMode: ClaudeOAuthKeychainPromptMode { get { let raw = self.defaultsState.claudeOAuthKeychainPromptModeRaw return ClaudeOAuthKeychainPromptMode(rawValue: raw ?? "") ?? .onlyOnUserAction } set { self.defaultsState.claudeOAuthKeychainPromptModeRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainPromptMode") } } var claudeOAuthKeychainReadStrategy: ClaudeOAuthKeychainReadStrategy { get { let raw = self.defaultsState.claudeOAuthKeychainReadStrategyRaw return ClaudeOAuthKeychainReadStrategy(rawValue: raw ?? "") ?? .securityFramework } set { self.defaultsState.claudeOAuthKeychainReadStrategyRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainReadStrategy") } } var claudeOAuthPromptFreeCredentialsEnabled: Bool { get { self.claudeOAuthKeychainReadStrategy == .securityCLIExperimental } set { self.claudeOAuthKeychainReadStrategy = newValue ? .securityCLIExperimental : .securityFramework } } var claudeWebExtrasEnabled: Bool { get { self.claudeWebExtrasEnabledRaw } set { self.claudeWebExtrasEnabledRaw = newValue } } private var claudeWebExtrasEnabledRaw: Bool { get { self.defaultsState.claudeWebExtrasEnabledRaw } set { self.defaultsState.claudeWebExtrasEnabledRaw = newValue self.userDefaults.set(newValue, forKey: "claudeWebExtrasEnabled") CodexBarLog.logger(LogCategories.settings).info( "Claude web extras updated", metadata: ["enabled": newValue ? "1" : "0"]) } } var showOptionalCreditsAndExtraUsage: Bool { get { self.defaultsState.showOptionalCreditsAndExtraUsage } set { self.defaultsState.showOptionalCreditsAndExtraUsage = newValue self.userDefaults.set(newValue, forKey: "showOptionalCreditsAndExtraUsage") } } var openAIWebAccessEnabled: Bool { get { self.defaultsState.openAIWebAccessEnabled } set { self.defaultsState.openAIWebAccessEnabled = newValue self.userDefaults.set(newValue, forKey: "openAIWebAccessEnabled") CodexBarLog.logger(LogCategories.settings).info( "OpenAI web access updated", metadata: ["enabled": newValue ? "1" : "0"]) } } var jetbrainsIDEBasePath: String { get { self.defaultsState.jetbrainsIDEBasePath } set { self.defaultsState.jetbrainsIDEBasePath = newValue self.userDefaults.set(newValue, forKey: "jetbrainsIDEBasePath") } } var mergeIcons: Bool { get { self.defaultsState.mergeIcons } set { self.defaultsState.mergeIcons = newValue self.userDefaults.set(newValue, forKey: "mergeIcons") } } var switcherShowsIcons: Bool { get { self.defaultsState.switcherShowsIcons } set { self.defaultsState.switcherShowsIcons = newValue self.userDefaults.set(newValue, forKey: "switcherShowsIcons") } } var mergedMenuLastSelectedWasOverview: Bool { get { self.defaultsState.mergedMenuLastSelectedWasOverview } set { self.defaultsState.mergedMenuLastSelectedWasOverview = newValue self.userDefaults.set(newValue, forKey: "mergedMenuLastSelectedWasOverview") } } private var mergedOverviewSelectedProvidersRaw: [String] { get { self.defaultsState.mergedOverviewSelectedProvidersRaw } set { self.defaultsState.mergedOverviewSelectedProvidersRaw = newValue self.userDefaults.set(newValue, forKey: "mergedOverviewSelectedProviders") } } private var selectedMenuProviderRaw: String? { get { self.defaultsState.selectedMenuProviderRaw } set { self.defaultsState.selectedMenuProviderRaw = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "selectedMenuProvider") } else { self.userDefaults.removeObject(forKey: "selectedMenuProvider") } } } var selectedMenuProvider: UsageProvider? { get { self.selectedMenuProviderRaw.flatMap(UsageProvider.init(rawValue:)) } set { self.selectedMenuProviderRaw = newValue?.rawValue } } var mergedOverviewSelectedProviders: [UsageProvider] { get { Self.decodeProviders( self.mergedOverviewSelectedProvidersRaw, maxCount: Self.mergedOverviewProviderLimit) } set { let normalized = Self.normalizeProviders(newValue, maxCount: Self.mergedOverviewProviderLimit) self.mergedOverviewSelectedProvidersRaw = normalized.map(\.rawValue) } } private var hasMergedOverviewSelectionPreference: Bool { self.userDefaults.object(forKey: "mergedOverviewSelectedProviders") != nil } private var mergedOverviewSelectionEditedActiveProvidersRaw: [String]? { get { self.userDefaults.array(forKey: Self.mergedOverviewSelectionEditedActiveProvidersKey) as? [String] } set { if let newValue { self.userDefaults.set(newValue, forKey: Self.mergedOverviewSelectionEditedActiveProvidersKey) } else { self.userDefaults.removeObject(forKey: Self.mergedOverviewSelectionEditedActiveProvidersKey) } } } private func mergedOverviewSelectionApplies(to activeProviders: [UsageProvider]) -> Bool { guard let editedRaw = self.mergedOverviewSelectionEditedActiveProvidersRaw else { return false } let editedSet = Set(editedRaw) let activeSet = Set(Self.normalizeProviders(activeProviders).map(\.rawValue)) return editedSet == activeSet } private func markMergedOverviewSelectionEdited(for activeProviders: [UsageProvider]) { let signature = Set(Self.normalizeProviders(activeProviders).map(\.rawValue)) self.mergedOverviewSelectionEditedActiveProvidersRaw = Array(signature).sorted() } private func clearMergedOverviewSelectionPreference() { self.defaultsState.mergedOverviewSelectedProvidersRaw = [] self.userDefaults.removeObject(forKey: "mergedOverviewSelectedProviders") self.mergedOverviewSelectionEditedActiveProvidersRaw = nil } func resolvedMergedOverviewProviders( activeProviders: [UsageProvider], maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] { guard maxVisibleProviders > 0 else { return [] } let normalizedActive = Self.normalizeProviders(activeProviders) guard self.hasMergedOverviewSelectionPreference else { return Array(normalizedActive.prefix(maxVisibleProviders)) } if normalizedActive.count <= maxVisibleProviders, !self.mergedOverviewSelectionApplies(to: normalizedActive) { return normalizedActive } let selectedSet = Set(self.mergedOverviewSelectedProviders) return Array(normalizedActive.filter { selectedSet.contains($0) }.prefix(maxVisibleProviders)) } @discardableResult func reconcileMergedOverviewSelectedProviders( activeProviders: [UsageProvider], maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] { guard maxVisibleProviders > 0 else { self.clearMergedOverviewSelectionPreference() return [] } let normalizedActive = Self.normalizeProviders(activeProviders) if normalizedActive.isEmpty { self.clearMergedOverviewSelectionPreference() return [] } let shouldPersistResolvedSelection = normalizedActive.count > maxVisibleProviders || self.mergedOverviewSelectionApplies(to: normalizedActive) if self.hasMergedOverviewSelectionPreference, shouldPersistResolvedSelection { let selectedSet = Set(self.mergedOverviewSelectedProviders) let sanitizedSelection = Array( normalizedActive .filter { selectedSet.contains($0) } .prefix(maxVisibleProviders)) if sanitizedSelection != self.mergedOverviewSelectedProviders { self.mergedOverviewSelectedProviders = sanitizedSelection } } return self.resolvedMergedOverviewProviders( activeProviders: normalizedActive, maxVisibleProviders: maxVisibleProviders) } @discardableResult func setMergedOverviewProviderSelection( provider: UsageProvider, isSelected: Bool, activeProviders: [UsageProvider], maxVisibleProviders: Int = SettingsStore.mergedOverviewProviderLimit) -> [UsageProvider] { guard maxVisibleProviders > 0 else { self.clearMergedOverviewSelectionPreference() return [] } let normalizedActive = Self.normalizeProviders(activeProviders) guard normalizedActive.contains(provider) else { return self.resolvedMergedOverviewProviders( activeProviders: normalizedActive, maxVisibleProviders: maxVisibleProviders) } let currentSelection = self.resolvedMergedOverviewProviders( activeProviders: normalizedActive, maxVisibleProviders: maxVisibleProviders) var updatedSet = Set(currentSelection) if isSelected { guard updatedSet.contains(provider) || currentSelection.count < maxVisibleProviders else { return currentSelection } updatedSet.insert(provider) } else { updatedSet.remove(provider) } let updatedSelection = Array( normalizedActive .filter { updatedSet.contains($0) } .prefix(maxVisibleProviders)) self.mergedOverviewSelectedProviders = updatedSelection self.markMergedOverviewSelectionEdited(for: normalizedActive) return updatedSelection } var providerDetectionCompleted: Bool { get { self.defaultsState.providerDetectionCompleted } set { self.defaultsState.providerDetectionCompleted = newValue self.userDefaults.set(newValue, forKey: "providerDetectionCompleted") } } var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } } } extension SettingsStore { private static func normalizeProviders(_ providers: [UsageProvider], maxCount: Int? = nil) -> [UsageProvider] { var seen: Set = [] var normalized: [UsageProvider] = [] for provider in providers where !seen.contains(provider) { seen.insert(provider) normalized.append(provider) if let maxCount, normalized.count >= maxCount { break } } return normalized } private static func decodeProviders(_ rawProviders: [String], maxCount: Int? = nil) -> [UsageProvider] { var providers: [UsageProvider] = [] providers.reserveCapacity(rawProviders.count) for raw in rawProviders { guard let provider = UsageProvider(rawValue: raw) else { continue } providers.append(provider) } return self.normalizeProviders(providers, maxCount: maxCount) } } ================================================ FILE: Sources/CodexBar/SettingsStore+MenuObservation.swift ================================================ import Foundation extension SettingsStore { var menuObservationToken: Int { _ = self.providerOrder _ = self.providerEnablement _ = self.refreshFrequency _ = self.launchAtLogin _ = self.debugMenuEnabled _ = self.debugDisableKeychainAccess _ = self.debugKeepCLISessionsAlive _ = self.statusChecksEnabled _ = self.sessionQuotaNotificationsEnabled _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.menuBarShowsBrandIconWithPercent _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode _ = self.historicalTrackingEnabled _ = self.showAllTokenAccountsInMenu _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled _ = self.hidePersonalInfo _ = self.randomBlinkEnabled _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled _ = self.codexUsageDataSource _ = self.claudeUsageDataSource _ = self.kiloUsageDataSource _ = self.kiloExtrasEnabled _ = self.codexCookieSource _ = self.claudeCookieSource _ = self.cursorCookieSource _ = self.opencodeCookieSource _ = self.factoryCookieSource _ = self.minimaxCookieSource _ = self.minimaxAPIRegion _ = self.kimiCookieSource _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.mergedMenuLastSelectedWasOverview _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken _ = self.syntheticAPIToken _ = self.codexCookieHeader _ = self.claudeCookieHeader _ = self.cursorCookieHeader _ = self.opencodeCookieHeader _ = self.opencodeWorkspaceID _ = self.factoryCookieHeader _ = self.minimaxCookieHeader _ = self.minimaxAPIToken _ = self.kimiManualCookieHeader _ = self.kimiK2APIToken _ = self.kiloAPIToken _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider _ = self.configRevision return 0 } } ================================================ FILE: Sources/CodexBar/SettingsStore+MenuPreferences.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference { if provider == .zai { return .primary } if provider == .openrouter { let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic switch preference { case .automatic, .primary: return preference case .secondary, .average: return .automatic } } let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic if preference == .average, !self.menuBarMetricSupportsAverage(for: provider) { return .automatic } return preference } func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) { if provider == .zai { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.primary.rawValue return } if provider == .openrouter { switch preference { case .automatic, .primary: self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue case .secondary, .average: self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue } return } self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue } func menuBarMetricSupportsAverage(for provider: UsageProvider) -> Bool { provider == .gemini } func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { self.costUsageEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost } var resetTimeDisplayStyle: ResetTimeDisplayStyle { self.resetTimesShowAbsolute ? .absolute : .countdown } } ================================================ FILE: Sources/CodexBar/SettingsStore+ProviderDetection.swift ================================================ import CodexBarCore import Foundation extension SettingsStore { func runInitialProviderDetectionIfNeeded(force: Bool = false) { guard force || !self.providerDetectionCompleted else { return } LoginShellPathCache.shared.captureOnce { [weak self] _ in Task { @MainActor in await self?.applyProviderDetection() } } } func applyProviderDetection() async { guard !self.providerDetectionCompleted else { return } let codexInstalled = BinaryLocator.resolveCodexBinary() != nil let claudeInstalled = BinaryLocator.resolveClaudeBinary() != nil let geminiInstalled = BinaryLocator.resolveGeminiBinary() != nil let antigravityRunning = await AntigravityStatusProbe.isRunning() let logger = CodexBarLog.logger(LogCategories.providerDetection) // If none installed, keep Codex enabled to match previous behavior. let noneInstalled = !codexInstalled && !claudeInstalled && !geminiInstalled && !antigravityRunning let enableCodex = codexInstalled || noneInstalled let enableClaude = claudeInstalled let enableGemini = geminiInstalled let enableAntigravity = antigravityRunning logger.info( "Provider detection results", metadata: [ "codexInstalled": codexInstalled ? "1" : "0", "claudeInstalled": claudeInstalled ? "1" : "0", "geminiInstalled": geminiInstalled ? "1" : "0", "antigravityRunning": antigravityRunning ? "1" : "0", ]) logger.info( "Provider detection enablement", metadata: [ "codex": enableCodex ? "1" : "0", "claude": enableClaude ? "1" : "0", "gemini": enableGemini ? "1" : "0", "antigravity": enableAntigravity ? "1" : "0", ]) self.updateProviderConfig(provider: .codex) { entry in entry.enabled = enableCodex } self.updateProviderConfig(provider: .claude) { entry in entry.enabled = enableClaude } self.updateProviderConfig(provider: .gemini) { entry in entry.enabled = enableGemini } self.updateProviderConfig(provider: .antigravity) { entry in entry.enabled = enableAntigravity } self.providerDetectionCompleted = true logger.info("Provider detection completed") } } ================================================ FILE: Sources/CodexBar/SettingsStore+TokenAccounts.swift ================================================ import AppKit import CodexBarCore import Foundation extension SettingsStore { func tokenAccountsData(for provider: UsageProvider) -> ProviderTokenAccountData? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } return self.configSnapshot.providerConfig(for: provider)?.tokenAccounts } func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { self.tokenAccountsData(for: provider)?.accounts ?? [] } func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil } let index = data.clampedActiveIndex() return data.accounts[index] } func setActiveTokenAccountIndex(_ index: Int, for provider: UsageProvider) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let clamped = min(max(index, 0), data.accounts.count - 1) let updated = ProviderTokenAccountData( version: data.version, accounts: data.accounts, activeIndex: clamped) self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Active token account updated", metadata: [ "provider": provider.rawValue, "index": "\(clamped)", ]) } func addTokenAccount(provider: UsageProvider, label: String, token: String) { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return } let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedToken.isEmpty else { return } let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) let existing = self.tokenAccountsData(for: provider) let accounts = existing?.accounts ?? [] let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel let account = ProviderTokenAccount( id: UUID(), label: fallbackLabel, token: trimmedToken, addedAt: Date().timeIntervalSince1970, lastUsed: nil) let updated = ProviderTokenAccountData( version: existing?.version ?? 1, accounts: accounts + [account], activeIndex: accounts.count) self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account added", metadata: [ "provider": provider.rawValue, "count": "\(updated.accounts.count)", ]) } func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { let clamped = min(max(data.activeIndex, 0), filtered.count - 1) entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, activeIndex: clamped) } } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account removed", metadata: [ "provider": provider.rawValue, "count": "\(filtered.count)", ]) } func ensureTokenAccountsLoaded() { if self.tokenAccountsLoaded { return } self.tokenAccountsLoaded = true } func reloadTokenAccounts() { let log = CodexBarLog.logger(LogCategories.tokenAccounts) let accounts: [UsageProvider: ProviderTokenAccountData] do { guard let loaded = try self.configStore.load() else { return } accounts = Dictionary(uniqueKeysWithValues: loaded.providers.compactMap { entry in guard let data = entry.tokenAccounts else { return nil } return (entry.id, data) }) } catch { log.error("Failed to reload token accounts: \(error)") return } self.tokenAccountsLoaded = true self.updateProviderTokenAccounts(accounts) } func openTokenAccountsFile() { do { try self.configStore.save(self.config) } catch { CodexBarLog.logger(LogCategories.tokenAccounts).error("Failed to persist config: \(error)") return } NSWorkspace.shared.open(self.configStore.fileURL) } private func applyTokenAccountCookieSourceIfNeeded(provider: UsageProvider) { guard let support = TokenAccountSupportCatalog.support(for: provider), support.requiresManualCookieSource else { return } ProviderCatalog.implementation(for: provider)?.applyTokenAccountCookieSource(settings: self) } } ================================================ FILE: Sources/CodexBar/SettingsStore+TokenCost.swift ================================================ import Foundation extension SettingsStore { func applyTokenCostDefaultIfNeeded() { // Settings are persisted in UserDefaults.standard. guard UserDefaults.standard.object(forKey: "tokenCostUsageEnabled") == nil else { return } Task { @MainActor [weak self] in guard let self else { return } let hasSources = await Task.detached(priority: .utility) { Self.hasAnyTokenCostUsageSources() }.value guard hasSources else { return } guard UserDefaults.standard.object(forKey: "tokenCostUsageEnabled") == nil else { return } self.costUsageEnabled = true } } nonisolated static func hasAnyTokenCostUsageSources( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default) -> Bool { func hasAnyJsonl(in root: URL) -> Bool { guard fileManager.fileExists(atPath: root.path) else { return false } guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return false } for case let url as URL in enumerator where url.pathExtension.lowercased() == "jsonl" { return true } return false } let codexRoot: URL = { let raw = env["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let raw, !raw.isEmpty { return URL(fileURLWithPath: raw).appendingPathComponent("sessions", isDirectory: true) } return fileManager.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) }() let archivedCodexRoot: URL? = { guard codexRoot.lastPathComponent == "sessions" else { return nil } return codexRoot .deletingLastPathComponent() .appendingPathComponent("archived_sessions", isDirectory: true) }() if hasAnyJsonl(in: codexRoot) { return true } if let archivedCodexRoot, hasAnyJsonl(in: archivedCodexRoot) { return true } let claudeRoots: [URL] = { if let env = env["CLAUDE_CONFIG_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), !env.isEmpty { return env.split(separator: ",").map { part in let raw = String(part).trimmingCharacters(in: .whitespacesAndNewlines) let url = URL(fileURLWithPath: raw) if url.lastPathComponent == "projects" { return url } return url.appendingPathComponent("projects", isDirectory: true) } } let home = fileManager.homeDirectoryForCurrentUser return [ home.appendingPathComponent(".config/claude/projects", isDirectory: true), home.appendingPathComponent(".claude/projects", isDirectory: true), ] }() return claudeRoots.contains(where: hasAnyJsonl(in:)) } } ================================================ FILE: Sources/CodexBar/SettingsStore.swift ================================================ import AppKit import CodexBarCore import Observation import ServiceManagement enum RefreshFrequency: String, CaseIterable, Identifiable { case manual case oneMinute case twoMinutes case fiveMinutes case fifteenMinutes case thirtyMinutes var id: String { self.rawValue } var seconds: TimeInterval? { switch self { case .manual: nil case .oneMinute: 60 case .twoMinutes: 120 case .fiveMinutes: 300 case .fifteenMinutes: 900 case .thirtyMinutes: 1800 } } var label: String { switch self { case .manual: "Manual" case .oneMinute: "1 min" case .twoMinutes: "2 min" case .fiveMinutes: "5 min" case .fifteenMinutes: "15 min" case .thirtyMinutes: "30 min" } } } enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case automatic case primary case secondary case average var id: String { self.rawValue } var label: String { switch self { case .automatic: "Automatic" case .primary: "Primary" case .secondary: "Secondary" case .average: "Average" } } } @MainActor @Observable final class SettingsStore { static let sharedDefaults = UserDefaults(suiteName: "group.com.steipete.codexbar") static let mergedOverviewProviderLimit = 3 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil }() @ObservationIgnored let userDefaults: UserDefaults @ObservationIgnored let configStore: CodexBarConfigStore @ObservationIgnored var config: CodexBarConfig @ObservationIgnored var configPersistTask: Task? @ObservationIgnored var configLoading = false @ObservationIgnored var tokenAccountsLoaded = false var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] init( userDefaults: UserDefaults = .standard, configStore: CodexBarConfigStore = CodexBarConfigStore(), zaiTokenStore: any ZaiTokenStoring = KeychainZaiTokenStore(), syntheticTokenStore: any SyntheticTokenStoring = KeychainSyntheticTokenStore(), codexCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "codex-cookie", promptKind: .codexCookie), claudeCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "claude-cookie", promptKind: .claudeCookie), cursorCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "cursor-cookie", promptKind: .cursorCookie), opencodeCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "opencode-cookie", promptKind: .opencodeCookie), factoryCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "factory-cookie", promptKind: .factoryCookie), minimaxCookieStore: any MiniMaxCookieStoring = KeychainMiniMaxCookieStore(), minimaxAPITokenStore: any MiniMaxAPITokenStoring = KeychainMiniMaxAPITokenStore(), kimiTokenStore: any KimiTokenStoring = KeychainKimiTokenStore(), kimiK2TokenStore: any KimiK2TokenStoring = KeychainKimiK2TokenStore(), augmentCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "augment-cookie", promptKind: .augmentCookie), ampCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "amp-cookie", promptKind: .ampCookie), copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, codexCookieStore: codexCookieStore, claudeCookieStore: claudeCookieStore, cursorCookieStore: cursorCookieStore, opencodeCookieStore: opencodeCookieStore, factoryCookieStore: factoryCookieStore, minimaxCookieStore: minimaxCookieStore, minimaxAPITokenStore: minimaxAPITokenStore, kimiTokenStore: kimiTokenStore, kimiK2TokenStore: kimiK2TokenStore, augmentCookieStore: augmentCookieStore, ampCookieStore: ampCookieStore, copilotTokenStore: copilotTokenStore, tokenAccountStore: tokenAccountStore) let config = CodexBarConfigMigrator.loadOrMigrate( configStore: configStore, userDefaults: userDefaults, stores: legacyStores) self.userDefaults = userDefaults self.configStore = configStore self.config = config self.configLoading = true self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) self.updateProviderState(config: config) self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) userDefaults.removeObject(forKey: "showCodexUsage") userDefaults.removeObject(forKey: "showClaudeUsage") LaunchAtLoginManager.setEnabled(self.launchAtLogin) self.runInitialProviderDetectionIfNeeded() self.ensureAlibabaProviderAutoEnabledIfNeeded() self.applyTokenCostDefaultIfNeeded() if self.claudeUsageDataSource != .cli { self.claudeWebExtrasEnabled = false } self.openAIWebAccessEnabled = self.codexCookieSource.isEnabled Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess } } extension SettingsStore { private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { let refreshRaw = userDefaults.string(forKey: "refreshFrequency") ?? RefreshFrequency.fiveMinutes.rawValue let refreshFrequency = RefreshFrequency(rawValue: refreshRaw) ?? .fiveMinutes let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false let debugDisableKeychainAccess: Bool = { if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { return stored } if let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool { userDefaults.set(shared, forKey: "debugDisableKeychainAccess") return shared } return false }() let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue if userDefaults.string(forKey: "debugLogLevel") == nil { userDefaults.set(debugLogLevelRaw, forKey: "debugLogLevel") } let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern") let debugKeepCLISessionsAlive = userDefaults.object(forKey: "debugKeepCLISessionsAlive") as? Bool ?? false let statusChecksEnabled = userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true let sessionQuotaDefault = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool let sessionQuotaNotificationsEnabled = sessionQuotaDefault ?? true if sessionQuotaDefault == nil { userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") } let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] var resolvedPreferences = storedPreferences if resolvedPreferences.isEmpty, let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) { resolvedPreferences = Dictionary( uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) } let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool let openAIWebAccessEnabled = openAIWebAccessDefault ?? true if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( forKey: "mergedMenuLastSelectedWasOverview") as? Bool ?? false let mergedOverviewSelectedProvidersRaw = userDefaults.array( forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false return SettingsDefaultsState( refreshFrequency: refreshFrequency, launchAtLogin: launchAtLogin, debugMenuEnabled: debugMenuEnabled, debugDisableKeychainAccess: debugDisableKeychainAccess, debugFileLoggingEnabled: debugFileLoggingEnabled, debugLogLevelRaw: debugLogLevelRaw, debugLoadingPatternRaw: debugLoadingPatternRaw, debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, statusChecksEnabled: statusChecksEnabled, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, showAllTokenAccountsInMenu: showAllTokenAccountsInMenu, menuBarMetricPreferencesRaw: resolvedPreferences, costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, menuBarShowsHighestUsage: menuBarShowsHighestUsage, claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, providerDetectionCompleted: providerDetectionCompleted) } } extension SettingsStore { var configSnapshot: CodexBarConfig { _ = self.configRevision return self.config } func updateProviderState(config: CodexBarConfig) { let rawOrder = config.providers.map(\.id.rawValue) self.providerOrder = Self.effectiveProviderOrder(raw: rawOrder) let metadata = ProviderDescriptorRegistry.metadata var enablement: [UsageProvider: Bool] = [:] enablement.reserveCapacity(metadata.count) for provider in UsageProvider.allCases { let defaultEnabled = metadata[provider]?.defaultEnabled ?? false enablement[provider] = config.providerConfig(for: provider)?.enabled ?? defaultEnabled } self.providerEnablement = enablement } func orderedProviders() -> [UsageProvider] { if self.providerOrder.isEmpty { self.updateProviderState(config: self.configSnapshot) } return self.providerOrder } func moveProvider(fromOffsets: IndexSet, toOffset: Int) { var order = self.orderedProviders() order.move(fromOffsets: fromOffsets, toOffset: toOffset) self.setProviderOrder(order) } func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool { self.providerEnablement[provider] ?? metadata.defaultEnabled } func isProviderEnabledCached( provider: UsageProvider, metadataByProvider: [UsageProvider: ProviderMetadata]) -> Bool { let defaultEnabled = metadataByProvider[provider]?.defaultEnabled ?? false return self.providerEnablement[provider] ?? defaultEnabled } func enabledProvidersOrdered(metadataByProvider: [UsageProvider: ProviderMetadata]) -> [UsageProvider] { _ = metadataByProvider return self.orderedProviders().filter { self.providerEnablement[$0] ?? false } } func setProviderEnabled(provider: UsageProvider, metadata _: ProviderMetadata, enabled: Bool) { CodexBarLog.logger(LogCategories.settings).debug( "Provider toggle updated", metadata: ["provider": provider.rawValue, "enabled": "\(enabled)"]) self.updateProviderConfig(provider: provider) { entry in entry.enabled = enabled } } func rerunProviderDetection() { self.runInitialProviderDetectionIfNeeded(force: true) } } extension SettingsStore { private static func effectiveProviderOrder(raw: [String]) -> [UsageProvider] { var seen: Set = [] var ordered: [UsageProvider] = [] for rawValue in raw { guard let provider = UsageProvider(rawValue: rawValue) else { continue } guard !seen.contains(provider) else { continue } seen.insert(provider) ordered.append(provider) } if ordered.isEmpty { ordered = UsageProvider.allCases seen = Set(ordered) } if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) { ordered.insert(.factory, at: zaiIndex) seen.insert(.factory) } if !seen.contains(.minimax), let zaiIndex = ordered.firstIndex(of: .zai) { let insertIndex = ordered.index(after: zaiIndex) ordered.insert(.minimax, at: insertIndex) seen.insert(.minimax) } for provider in UsageProvider.allCases where !seen.contains(provider) { ordered.append(provider) } return ordered } } ================================================ FILE: Sources/CodexBar/SettingsStoreState.swift ================================================ import Foundation struct SettingsDefaultsState { var refreshFrequency: RefreshFrequency var launchAtLogin: Bool var debugMenuEnabled: Bool var debugDisableKeychainAccess: Bool var debugFileLoggingEnabled: Bool var debugLogLevelRaw: String? var debugLoadingPatternRaw: String? var debugKeepCLISessionsAlive: Bool var statusChecksEnabled: Bool var sessionQuotaNotificationsEnabled: Bool var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool var showAllTokenAccountsInMenu: Bool var menuBarMetricPreferencesRaw: [String: String] var costUsageEnabled: Bool var hidePersonalInfo: Bool var randomBlinkEnabled: Bool var menuBarShowsHighestUsage: Bool var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool var switcherShowsIcons: Bool var mergedMenuLastSelectedWasOverview: Bool var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool } ================================================ FILE: Sources/CodexBar/StatusItemController+Actions.swift ================================================ import AppKit import CodexBarCore extension StatusItemController { // MARK: - Actions reachable from menus func refreshStore(forceTokenUsage: Bool) { Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) } } } @objc func refreshNow() { self.refreshStore(forceTokenUsage: true) } @objc func refreshAugmentSession() { Task { await self.store.forceRefreshAugmentSession() // Also trigger a full refresh to update the menu and clear any stale errors await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: false) } } } @objc func installUpdate() { self.updater.checkForUpdates(nil) } @objc func openDashboard() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) let provider = preferred ?? .codex guard let url = self.dashboardURL(for: provider) else { return } NSWorkspace.shared.open(url) } func dashboardURL(for provider: UsageProvider) -> URL? { if provider == .alibaba { return self.settings.alibabaCodingPlanAPIRegion.dashboardURL } let meta = self.store.metadata(for: provider) let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL } else { meta.dashboardURL } guard let urlString else { return nil } return URL(string: urlString) } @objc func openCreditsPurchase() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) let provider = preferred ?? .codex guard provider == .codex else { return } let dashboardURL = self.store.metadata(for: .codex).dashboardURL let purchaseURL = Self.sanitizedCreditsPurchaseURL(self.store.openAIDashboard?.creditsPurchaseURL) let urlString = purchaseURL ?? dashboardURL guard let urlString, let url = URL(string: urlString) else { return } let autoStart = true let accountEmail = self.store.codexAccountEmailForOpenAIDashboard() let controller = self.creditsPurchaseWindow ?? OpenAICreditsPurchaseWindowController() controller.show(purchaseURL: url, accountEmail: accountEmail, autoStartPurchase: autoStart) self.creditsPurchaseWindow = controller } private static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? { guard let raw, let url = URL(string: raw) else { return nil } guard let host = url.host?.lowercased(), host.contains("chatgpt.com") else { return nil } let path = url.path.lowercased() let allowed = ["settings", "usage", "billing", "credits"] guard allowed.contains(where: { path.contains($0) }) else { return nil } return url.absoluteString } @objc func openStatusPage() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) let provider = preferred ?? .codex let meta = self.store.metadata(for: provider) let urlString = meta.statusPageURL ?? meta.statusLinkURL guard let urlString, let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" Self.openTerminal(command: command) } @objc func openLoginToProvider(_ sender: NSMenuItem) { guard let urlString = sender.representedObject as? String, let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } @objc func runSwitchAccount(_ sender: NSMenuItem) { if self.loginTask != nil { self.loginLogger.info("Switch Account tap ignored: login already in-flight") return } let rawProvider = sender.representedObject as? String let provider = rawProvider.flatMap(UsageProvider.init(rawValue:)) ?? self.lastMenuProvider ?? .codex self.loginLogger.info("Switch Account tapped", metadata: ["provider": provider.rawValue]) self.loginTask = Task { @MainActor [weak self] in guard let self else { return } defer { self.activeLoginProvider = nil self.loginTask = nil } self.activeLoginProvider = provider self.loginPhase = .requesting self.loginLogger.info("Starting login task", metadata: ["provider": provider.rawValue]) let shouldRefresh = await self.runLoginFlow(provider: provider) if shouldRefresh { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() } self.loginLogger.info("Triggered refresh after login", metadata: ["provider": provider.rawValue]) } } } @objc func showSettingsGeneral() { self.openSettings(tab: .general) } @objc func showSettingsAbout() { self.openSettings(tab: .about) } func openMenuFromShortcut() { if self.shouldMergeIcons { self.statusItem.button?.performClick(nil) return } let provider = self.resolvedShortcutProvider() // Use the lazy accessor to ensure the item exists let item = self.lazyStatusItem(for: provider) item.button?.performClick(nil) } private func openSettings(tab: PreferencesTab) { DispatchQueue.main.async { self.preferencesSelection.tab = tab NSApp.activate(ignoringOtherApps: true) NotificationCenter.default.post( name: .codexbarOpenSettings, object: nil, userInfo: ["tab": tab.rawValue]) } } @objc func quit() { NSApp.terminate(nil) } @objc func copyError(_ sender: NSMenuItem) { if let err = sender.representedObject as? String { let pb = NSPasteboard.general pb.clearContents() pb.setString(err, forType: .string) } } private static func openTerminal(command: String) { let escaped = command .replacingOccurrences(of: "\\\\", with: "\\\\\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let script = """ tell application "Terminal" activate do script "\(escaped)" end tell """ if let appleScript = NSAppleScript(source: script) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { CodexBarLog.logger(LogCategories.terminal).error( "Failed to open Terminal", metadata: ["error": String(describing: error)]) } } } private func resolvedShortcutProvider() -> UsageProvider { if let last = self.lastMenuProvider, self.isEnabled(last) { return last } if let first = self.store.enabledProviders().first { return first } return .codex } func presentCodexLoginResult(_ result: CodexLoginRunner.Result) { switch result.outcome { case .success: return case .missingBinary: self.presentLoginAlert( title: "Codex CLI not found", message: "Install the Codex CLI (npm i -g @openai/codex) and try again.") case let .launchFailed(message): self.presentLoginAlert(title: "Could not start codex login", message: message) case .timedOut: self.presentLoginAlert( title: "Codex login timed out", message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "codex login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: "Codex login failed", message: message) } } func presentClaudeLoginResult(_ result: ClaudeLoginRunner.Result) { switch result.outcome { case .success: return case .missingBinary: self.presentLoginAlert( title: "Claude CLI not found", message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.") case let .launchFailed(message): self.presentLoginAlert(title: "Could not start claude /login", message: message) case .timedOut: self.presentLoginAlert( title: "Claude login timed out", message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "claude /login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: "Claude login failed", message: message) } } func describe(_ outcome: CodexLoginRunner.Result.Outcome) -> String { switch outcome { case .success: "success" case .timedOut: "timedOut" case let .failed(status): "failed(status: \(status))" case .missingBinary: "missingBinary" case let .launchFailed(message): "launchFailed(\(message))" } } func describe(_ outcome: ClaudeLoginRunner.Result.Outcome) -> String { switch outcome { case .success: "success" case .timedOut: "timedOut" case let .failed(status): "failed(status: \(status))" case .missingBinary: "missingBinary" case let .launchFailed(message): "launchFailed(\(message))" } } func describe(_ outcome: GeminiLoginRunner.Result.Outcome) -> String { switch outcome { case .success: "success" case .missingBinary: "missingBinary" case let .launchFailed(message): "launchFailed(\(message))" } } func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) { guard let info = Self.geminiLoginAlertInfo(for: result) else { return } self.presentLoginAlert(title: info.title, message: info.message) } struct LoginAlertInfo: Equatable { let title: String let message: String } nonisolated static func geminiLoginAlertInfo(for result: GeminiLoginRunner.Result) -> LoginAlertInfo? { switch result.outcome { case .success: nil case .missingBinary: LoginAlertInfo( title: "Gemini CLI not found", message: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.") case let .launchFailed(message): LoginAlertInfo(title: "Could not open Terminal for Gemini", message: message) } } func presentLoginAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .warning alert.runModal() } private func trimmedLoginOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 if trimmed.isEmpty { return "No output captured." } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. String { switch outcome { case .success: "success" case .cancelled: "cancelled" case let .failed(message): "failed(\(message))" } } } ================================================ FILE: Sources/CodexBar/StatusItemController+Animation.swift ================================================ import AppKit import CodexBarCore import QuartzCore extension StatusItemController { private static let loadingPercentEpsilon = 0.0001 private static let blinkActiveTickInterval: Duration = .milliseconds(75) private static let blinkIdleFallbackInterval: Duration = .seconds(1) func needsMenuBarIconAnimation() -> Bool { if self.shouldMergeIcons { let primaryProvider = self.primaryProviderForUnifiedIcon() return self.shouldAnimate(provider: primaryProvider) } return UsageProvider.allCases.contains { self.shouldAnimate(provider: $0) } } func updateBlinkingState() { // During the loading animation, blink ticks can overwrite the animated menu bar icon and cause flicker. if self.needsMenuBarIconAnimation() { self.stopBlinking() return } let blinkingEnabled = self.isBlinkingAllowed() // Use display list so merged-mode visibility stays consistent with shouldMergeIcons. let displayProviders = self.store.enabledProvidersForDisplay() let anyEnabled = !displayProviders.isEmpty || self.store.debugForceAnimation let anyVisible = UsageProvider.allCases.contains { self.isVisible($0) } let mergeIcons = self.shouldMergeIcons let shouldBlink = mergeIcons ? anyEnabled : anyVisible if blinkingEnabled, shouldBlink { if self.blinkTask == nil { self.seedBlinkStatesIfNeeded() self.blinkTask = Task { [weak self] in while !Task.isCancelled { let delay = await MainActor.run { self?.blinkTickSleepDuration(now: Date()) ?? Self.blinkIdleFallbackInterval } try? await Task.sleep(for: delay) await MainActor.run { self?.tickBlink() } } } } } else { self.stopBlinking() } } private func seedBlinkStatesIfNeeded() { let now = Date() for provider in UsageProvider.allCases where self.blinkStates[provider] == nil { self.blinkStates[provider] = BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) } } private func stopBlinking() { self.blinkTask?.cancel() self.blinkTask = nil self.blinkAmounts.removeAll() let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { self.applyIcon(phase: phase) } else { for provider in UsageProvider.allCases { self.applyIcon(for: provider, phase: phase) } } } private func blinkTickSleepDuration(now: Date) -> Duration { let mergeIcons = self.shouldMergeIcons var nextWakeAt: Date? for provider in UsageProvider.allCases { let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider) guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) else { continue } let state = self .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) if state.blinkStart != nil { return Self.blinkActiveTickInterval } let candidate: Date = state.pendingSecondStart ?? state.nextBlink if let current = nextWakeAt { if candidate < current { nextWakeAt = candidate } } else { nextWakeAt = candidate } } guard let nextWakeAt else { return Self.blinkIdleFallbackInterval } let delay = nextWakeAt.timeIntervalSince(now) if delay <= 0 { return Self.blinkActiveTickInterval } return .seconds(delay) } private func tickBlink(now: Date = .init()) { guard self.isBlinkingAllowed(at: now) else { self.stopBlinking() return } let blinkDuration: TimeInterval = 0.36 let doubleBlinkChance = 0.18 let doubleDelayRange: ClosedRange = 0.22...0.34 // Cache merge state once per tick to avoid repeated enabled-provider lookups. let mergeIcons = self.shouldMergeIcons for provider in UsageProvider.allCases { let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider) guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) else { self.clearMotion(for: provider) continue } var state = self .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) if let pendingSecond = state.pendingSecondStart, now >= pendingSecond { state.blinkStart = now state.pendingSecondStart = nil } if let start = state.blinkStart { let elapsed = now.timeIntervalSince(start) if elapsed >= blinkDuration { state.blinkStart = nil if let pending = state.pendingSecondStart, now < pending { // Wait for the planned double-blink. } else { state.pendingSecondStart = nil state.nextBlink = now.addingTimeInterval(BlinkState.randomDelay()) } self.clearMotion(for: provider) } else { let progress = max(0, min(elapsed / blinkDuration, 1)) let symmetric = progress < 0.5 ? progress * 2 : (1 - progress) * 2 let eased = pow(symmetric, 2.2) // slightly punchier than smoothstep self.assignMotion(amount: CGFloat(eased), for: provider, effect: state.effect) } } else if now >= state.nextBlink { state.blinkStart = now state.effect = self.randomEffect(for: provider) if state.effect == .blink, Double.random(in: 0...1) < doubleBlinkChance { state.pendingSecondStart = now.addingTimeInterval(Double.random(in: doubleDelayRange)) } self.clearMotion(for: provider) } else { self.clearMotion(for: provider) } self.blinkStates[provider] = state if !mergeIcons { self.applyIcon(for: provider, phase: nil) } } if mergeIcons { let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil self.applyIcon(phase: phase) } } private func blinkAmount(for provider: UsageProvider) -> CGFloat { guard self.isBlinkingAllowed() else { return 0 } return self.blinkAmounts[provider] ?? 0 } private func wiggleAmount(for provider: UsageProvider) -> CGFloat { guard self.isBlinkingAllowed() else { return 0 } return self.wiggleAmounts[provider] ?? 0 } private func tiltAmount(for provider: UsageProvider) -> CGFloat { guard self.isBlinkingAllowed() else { return 0 } return self.tiltAmounts[provider] ?? 0 } private func assignMotion(amount: CGFloat, for provider: UsageProvider, effect: MotionEffect) { switch effect { case .blink: self.blinkAmounts[provider] = amount self.wiggleAmounts[provider] = 0 self.tiltAmounts[provider] = 0 case .wiggle: self.wiggleAmounts[provider] = amount self.blinkAmounts[provider] = 0 self.tiltAmounts[provider] = 0 case .tilt: self.tiltAmounts[provider] = amount self.blinkAmounts[provider] = 0 self.wiggleAmounts[provider] = 0 } } private func clearMotion(for provider: UsageProvider) { self.blinkAmounts[provider] = 0 self.wiggleAmounts[provider] = 0 self.tiltAmounts[provider] = 0 } private func randomEffect(for provider: UsageProvider) -> MotionEffect { if provider == .claude { Bool.random() ? .blink : .wiggle } else { Bool.random() ? .blink : .tilt } } private func isBlinkingAllowed(at date: Date = .init()) -> Bool { if self.settings.randomBlinkEnabled { return true } if let until = self.blinkForceUntil, until > date { return true } self.blinkForceUntil = nil return false } func applyIcon(phase: Double?) { guard let button = self.statusItem.button else { return } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let primaryProvider = self.primaryProviderForUnifiedIcon() let snapshot = self.store.snapshot(for: primaryProvider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent if showUsed, primaryProvider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. weekly = 0 } if showUsed, primaryProvider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, weekly == 0 { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } var credits: Double? = primaryProvider == .codex ? self.store.credits?.remaining : nil var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? let needsAnimation = self.needsMenuBarIconAnimation() if let phase, needsAnimation { var pattern = self.animationPattern if style == .combined, pattern == .unbraid { pattern = .cylon } if pattern == .unbraid { morphProgress = pattern.value(phase: phase) / 100 primary = nil weekly = nil credits = nil stale = false } else { // Keep loading animation layout stable: IconRenderer uses `weeklyRemaining > 0` to switch layouts, // so hitting an exact 0 would flip between "normal" and "weekly exhausted" rendering. primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) credits = nil stale = false } } let blink: CGFloat = style == .combined ? 0 : self.blinkAmount(for: primaryProvider) let wiggle: CGFloat = style == .combined ? 0 : self.wiggleAmount(for: primaryProvider) let tilt: CGFloat = style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 let statusIndicator: ProviderStatusIndicator = { for provider in self.store.enabledProvidersForDisplay() { let indicator = self.store.statusIndicator(for: provider) if indicator.hasIssue { return indicator } } return .none }() if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) return } if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), let brand = ProviderBrandIcon.image(for: primaryProvider) { self.setButtonTitle(nil, for: button) self.setButtonImage( Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), for: button) return } self.setButtonTitle(nil, for: button) if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) } else { let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, creditsRemaining: credits, stale: stale, style: style, blink: blink, wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator) self.setButtonImage(image, for: button) } } func applyIcon(for provider: UsageProvider, phase: Double?) { guard let button = self.statusItems[provider]?.button else { return } let snapshot = self.store.snapshot(for: provider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) return } if Self.shouldUseOpenRouterBrandFallback(provider: provider, snapshot: snapshot), let brand = ProviderBrandIcon.image(for: provider) { self.setButtonTitle(nil, for: button) self.setButtonImage( Self.brandImageWithStatusOverlay( brand: brand, statusIndicator: self.store.statusIndicator(for: provider)), for: button) return } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent if showUsed, provider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. weekly = 0 } if showUsed, provider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, weekly == 0 { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil var stale = self.store.isStale(provider: provider) var morphProgress: Double? if let phase, self.shouldAnimate(provider: provider) { var pattern = self.animationPattern if provider == .claude, pattern == .unbraid { pattern = .cylon } if pattern == .unbraid { morphProgress = pattern.value(phase: phase) / 100 primary = nil weekly = nil credits = nil stale = false } else { // Keep loading animation layout stable: IconRenderer switches layouts at `weeklyRemaining == 0`. primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) credits = nil stale = false } } let style: IconStyle = self.store.style(for: provider) let isLoading = phase != nil && self.shouldAnimate(provider: provider) let blink: CGFloat = { guard isLoading, style == .warp, let phase else { return self.blinkAmount(for: provider) } let normalized = (sin(phase * 3) + 1) / 2 return CGFloat(max(0, min(normalized, 1))) }() let wiggle = self.wiggleAmount(for: provider) let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) } else { self.setButtonTitle(nil, for: button) let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, creditsRemaining: credits, stale: stale, style: style, blink: blink, wiggle: wiggle, tilt: tilt, statusIndicator: self.store.statusIndicator(for: provider)) self.setButtonImage(image, for: button) } } private func setButtonImage(_ image: NSImage, for button: NSStatusBarButton) { if button.image === image { return } button.image = image } private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { let value = title ?? "" if button.title != value { button.title = value } let position: NSControl.ImagePosition = value.isEmpty ? .imageOnly : .imageLeft if button.imagePosition != position { button.imagePosition = position } } func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) let mode = self.settings.menuBarDisplayMode let now = Date() let pace: UsagePace? = switch mode { case .percent: nil case .pace, .both: snapshot?.secondary.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } } let displayText = MenuBarDisplayText.displayText( mode: mode, percentWindow: percentWindow, pace: pace, showUsed: self.settings.usageBarsShowUsed) let sessionExhausted = (snapshot?.primary?.remainingPercent ?? 100) <= 0 let weeklyExhausted = (snapshot?.secondary?.remainingPercent ?? 100) <= 0 if provider == .codex, mode == .percent, !self.settings.usageBarsShowUsed, sessionExhausted || weeklyExhausted, let creditsRemaining = self.store.credits?.remaining, creditsRemaining > 0 { return UsageFormatter .creditsString(from: creditsRemaining) .replacingOccurrences(of: " left", with: "") } return displayText } private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } private func primaryProviderForUnifiedIcon() -> UsageProvider { // When "show highest usage" is enabled, auto-select the provider closest to rate limit. if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons, let highest = self.store.providerWithHighestUsage() { return highest.provider } if self.shouldMergeIcons, let selected = self.selectedMenuProvider, self.store.isEnabled(selected) { return selected } for provider in UsageProvider.allCases { if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil { return provider } } // Use availability-filtered list: fallback must pick a provider that can // actually animate, otherwise shouldAnimate() fails on credential-less providers. if let enabled = self.store.enabledProviders().first { return enabled } return .codex } @objc func handleDebugBlinkNotification() { self.forceBlinkNow() } private func forceBlinkNow() { let now = Date() self.blinkForceUntil = now.addingTimeInterval(0.6) self.seedBlinkStatesIfNeeded() for provider in UsageProvider.allCases { let shouldBlink = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) guard shouldBlink, !self.shouldAnimate(provider: provider) else { continue } var state = self .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) state.blinkStart = now state.pendingSecondStart = nil state.effect = self.randomEffect(for: provider) state.nextBlink = now.addingTimeInterval(BlinkState.randomDelay()) self.blinkStates[provider] = state self.assignMotion(amount: 0, for: provider, effect: state.effect) } // If the blink task is currently in a long idle sleep, restart it so this forced blink // keeps animating on the active frame cadence immediately. self.blinkTask?.cancel() self.blinkTask = nil self.updateBlinkingState() self.tickBlink(now: now) } private func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { if self.store.debugForceAnimation { return true } let isMerged = mergeIcons ?? self.shouldMergeIcons let isVisible = isMerged ? self.isEnabled(provider) : self.isVisible(provider) guard isVisible else { return false } // Don't animate for fallback provider - it's only shown as a placeholder when nothing is enabled. // Animating the fallback causes unnecessary CPU usage (battery drain). See #269, #139. let isEnabled = self.isEnabled(provider) let isFallbackOnly = !isEnabled && self.fallbackProvider == provider if isFallbackOnly { return false } let isStale = self.store.isStale(provider: provider) let hasData = self.store.snapshot(for: provider) != nil if provider == .warp, !hasData, self.store.refreshingProviders.contains(provider) { return true } return !hasData && !isStale } func updateAnimationState() { let needsAnimation = self.needsMenuBarIconAnimation() if needsAnimation { if self.animationDriver == nil { if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else if !LoadingPattern.allCases.contains(self.animationPattern) { self.animationPattern = .knightRider } self.animationPhase = 0 let driver = DisplayLinkDriver(onTick: { [weak self] in self?.updateAnimationFrame() }) self.animationDriver = driver driver.start(fps: 60) } else if let forced = self.settings.debugLoadingPattern, forced != self.animationPattern { self.animationPattern = forced self.animationPhase = 0 } } else { self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 if self.shouldMergeIcons { self.applyIcon(phase: nil) } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: nil) } } } } private func updateAnimationFrame() { self.animationPhase += 0.045 // half-speed animation if self.shouldMergeIcons { self.applyIcon(phase: self.animationPhase) } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: self.animationPhase) } } } nonisolated static func shouldUseOpenRouterBrandFallback( provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { guard provider == .openrouter, let openRouterUsage = snapshot?.openRouterUsage else { return false } return openRouterUsage.keyQuotaStatus == .noLimitConfigured } nonisolated static func brandImageWithStatusOverlay( brand: NSImage, statusIndicator: ProviderStatusIndicator) -> NSImage { guard statusIndicator.hasIssue else { return brand } let image = NSImage(size: brand.size) image.lockFocus() brand.draw( at: .zero, from: NSRect(origin: .zero, size: brand.size), operation: .sourceOver, fraction: 1.0) Self.drawBrandStatusOverlay(indicator: statusIndicator, size: brand.size) image.unlockFocus() image.isTemplate = brand.isTemplate return image } private nonisolated static func drawBrandStatusOverlay(indicator: ProviderStatusIndicator, size: NSSize) { guard indicator.hasIssue else { return } let color = NSColor.labelColor switch indicator { case .minor, .maintenance: let dotSize = CGSize(width: 4, height: 4) let dotOrigin = CGPoint(x: size.width - dotSize.width - 2, y: 2) color.setFill() NSBezierPath(ovalIn: CGRect(origin: dotOrigin, size: dotSize)).fill() case .major, .critical, .unknown: color.setFill() let lineRect = CGRect(x: size.width - 6, y: 4, width: 2, height: 6) NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1).fill() let dotRect = CGRect(x: size.width - 6, y: 2, width: 2, height: 2) NSBezierPath(ovalIn: dotRect).fill() case .none: break } } private func advanceAnimationPattern() { let patterns = LoadingPattern.allCases if let idx = patterns.firstIndex(of: self.animationPattern) { let next = patterns.indices.contains(idx + 1) ? patterns[idx + 1] : patterns.first self.animationPattern = next ?? .knightRider } else { self.animationPattern = .knightRider } } @objc func handleDebugReplayNotification(_ notification: Notification) { if let raw = notification.userInfo?["pattern"] as? String, let selected = LoadingPattern(rawValue: raw) { self.animationPattern = selected } else if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else { self.advanceAnimationPattern() } self.animationPhase = 0 self.updateAnimationState() } } ================================================ FILE: Sources/CodexBar/StatusItemController+Menu.swift ================================================ import AppKit import CodexBarCore import Observation import QuartzCore import SwiftUI extension ProviderSwitcherSelection { fileprivate var provider: UsageProvider? { switch self { case .overview: nil case let .provider(provider): provider } } } private struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat var body: some View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( model: self.model, showDivider: self.hasUsageBlock, width: self.width) if self.hasUsageBlock { UsageMenuCardUsageSectionView( model: self.model, showBottomDivider: false, bottomPadding: 6, width: self.width) } } .frame(width: self.width, alignment: .leading) } private var hasUsageBlock: Bool { !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil } } // MARK: - NSMenu construction extension StatusItemController { private static let menuCardBaseWidth: CGFloat = 310 private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit private static let overviewRowIdentifierPrefix = "overviewRow-" private static let menuOpenRefreshDelay: Duration = .seconds(1.2) private struct OpenAIWebMenuItems { let hasUsageBreakdown: Bool let hasCreditsHistory: Bool let hasCostHistory: Bool } private struct TokenAccountMenuDisplay { let provider: UsageProvider let accounts: [ProviderTokenAccount] let snapshots: [TokenAccountUsageSnapshot] let activeIndex: Int let showAll: Bool let showSwitcher: Bool } private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu return Self.menuCardBaseWidth } func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) } let menu = NSMenu() menu.autoenablesItems = false menu.delegate = self return menu } func menuWillOpen(_ menu: NSMenu) { if self.isHostedSubviewMenu(menu) { self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } self.openMenus[ObjectIdentifier(menu)] = menu // Removed redundant async refresh - single pass is sufficient after initial layout return } var provider: UsageProvider? if self.shouldMergeIcons { let resolvedProvider = self.resolvedMenuProvider() self.lastMenuProvider = resolvedProvider ?? .codex provider = resolvedProvider } else { if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { self.lastMenuProvider = menuProvider provider = menuProvider } else if menu === self.fallbackMenu { self.lastMenuProvider = self.store.enabledProvidersForDisplay().first ?? .codex provider = nil } else { let resolved = self.store.enabledProvidersForDisplay().first ?? .codex self.lastMenuProvider = resolved provider = resolved } } let didRefresh = self.menuNeedsRefresh(menu) if didRefresh { self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } self.openMenus[ObjectIdentifier(menu)] = menu // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) } } func menuDidClose(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() let isPersistentMenu = menu === self.mergedMenu || menu === self.fallbackMenu || self.providerMenus.values.contains { $0 === menu } if !isPersistentMenu { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) } for menuItem in menu.items { (menuItem.view as? MenuCardHighlighting)?.setHighlighted(false) } } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { for menuItem in menu.items { let highlighted = menuItem == item && menuItem.isEnabled (menuItem.view as? MenuCardHighlighting)?.setHighlighted(highlighted) } } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 ? self.resolvedSwitcherSelection( enabledProviders: enabledProviders, includesOverview: includesOverview) : nil let isOverviewSelected = switcherSelection == .overview let selectedProvider = if isOverviewSelected { self.resolvedMenuProvider(enabledProviders: enabledProviders) } else { switcherSelection?.provider ?? provider } let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) let showAllTokenAccounts = tokenAccountDisplay?.showAll ?? false let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) let hasTokenAccountSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherSelectionMatches && switcherOverviewAvailabilityMatches && tokenAccountDisplay == nil && !hasTokenAccountSwitcher && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView if canSmartUpdate { self.updateMenuContent( menu, provider: selectedProvider, currentProvider: currentProvider, menuWidth: menuWidth, openAIContext: openAIContext) return } menu.removeAllItems() let descriptor = MenuDescriptor.build( provider: selectedProvider, store: self.store, settings: self.settings, account: self.account, updateReady: self.updater.updateStatus.isUpdateReady, includeContextualActions: !isOverviewSelected) self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: enabledProviders, includesOverview: includesOverview, selection: switcherSelection ?? .provider(currentProvider)) // Track which providers the switcher was built with for smart update detection if self.shouldMergeIcons, enabledProviders.count > 1 { self.lastSwitcherProviders = enabledProviders self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed self.lastMergedSwitcherSelection = switcherSelection self.lastSwitcherIncludesOverview = includesOverview } self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, menuWidth: menuWidth, tokenAccountDisplay: tokenAccountDisplay, openAIContext: openAIContext) if isOverviewSelected { if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, menuWidth: menuWidth) { menu.addItem(.separator()) } else { self.addOverviewEmptyState(to: menu, enabledProviders: enabledProviders) menu.addItem(.separator()) } } else { let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) self.addOpenAIWebItemsIfNeeded( to: menu, currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) } /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). private func updateMenuContent( _ menu: NSMenu, provider: UsageProvider?, currentProvider: UsageProvider, menuWidth: CGFloat, openAIContext: OpenAIWebContext) { // Batch menu updates to prevent visual flickering during provider switch. CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } var contentStartIndex = 0 if menu.items.first?.view is ProviderSwitcherView { contentStartIndex = 2 } if menu.items.count > contentStartIndex, menu.items[contentStartIndex].view is TokenAccountSwitcherView { contentStartIndex += 2 } while menu.items.count > contentStartIndex { menu.removeItem(at: contentStartIndex) } let descriptor = MenuDescriptor.build( provider: provider, store: self.store, settings: self.settings, account: self.account, updateReady: self.updater.updateStatus.isUpdateReady) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: provider, menuWidth: menuWidth, tokenAccountDisplay: nil, openAIContext: openAIContext) let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) self.addOpenAIWebItemsIfNeeded( to: menu, currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) } private struct OpenAIWebContext { let hasUsageBreakdown: Bool let hasCreditsHistory: Bool let hasCostHistory: Bool let hasOpenAIWebMenuItems: Bool } private struct MenuCardContext { let currentProvider: UsageProvider let selectedProvider: UsageProvider? let menuWidth: CGFloat let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext } private func openAIWebContext( currentProvider: UsageProvider, showAllTokenAccounts: Bool) -> OpenAIWebContext { let dashboard = self.store.openAIDashboard let openAIWebEligible = currentProvider == .codex && self.store.openAIDashboardRequiresLogin == false && dashboard != nil let hasCreditsHistory = openAIWebEligible && !(dashboard?.dailyBreakdown ?? []).isEmpty let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let hasOpenAIWebMenuItems = !showAllTokenAccounts && (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) return OpenAIWebContext( hasUsageBreakdown: hasUsageBreakdown, hasCreditsHistory: hasCreditsHistory, hasCostHistory: hasCostHistory, hasOpenAIWebMenuItems: hasOpenAIWebMenuItems) } private func addProviderSwitcherIfNeeded( to menu: NSMenu, enabledProviders: [UsageProvider], includesOverview: Bool, selection: ProviderSwitcherSelection) { guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } let switcherItem = self.makeProviderSwitcherItem( providers: enabledProviders, includesOverview: includesOverview, selected: selection, menu: menu) menu.addItem(switcherItem) menu.addItem(.separator()) } private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) { guard let display, display.showSwitcher else { return } let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu) menu.addItem(switcherItem) menu.addItem(.separator()) } @discardableResult private func addOverviewRows( to menu: NSMenu, enabledProviders: [UsageProvider], menuWidth: CGFloat) -> Bool { let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders .compactMap { provider in guard let model = self.menuCardModel(for: provider) else { return nil } return (provider: provider, model: model) } guard !rows.isEmpty else { return false } for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let item = self.makeMenuCardItem( OverviewMenuCardRowView(model: row.model, width: menuWidth), id: identifier, width: menuWidth, onClick: { [weak self, weak menu] in guard let self, let menu else { return } self.selectOverviewProvider(row.provider, menu: menu) }) // Keep menu item action wired for keyboard activation and accessibility action paths. item.target = self item.action = #selector(self.selectOverviewProvider(_:)) menu.addItem(item) if index < rows.count - 1 { menu.addItem(.separator()) } } return true } private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders) let message = if resolvedProviders.isEmpty { "No providers selected for Overview." } else { "No overview data available." } let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") item.isEnabled = false item.representedObject = "overviewEmptyState" menu.addItem(item) } private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty ? [] : accountSnapshots.compactMap { accountSnapshot in self.menuCardModel( for: context.currentProvider, snapshotOverride: accountSnapshot.snapshot, errorOverride: accountSnapshot.error) } if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(index)", width: context.menuWidth)) if index < cards.count - 1 { menu.addItem(.separator()) } } if !cards.isEmpty { menu.addItem(.separator()) } } return false } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } if context.openAIContext.hasOpenAIWebMenuItems { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, hasCreditsHistory: context.openAIContext.hasCreditsHistory, hasCostHistory: context.openAIContext.hasCostHistory) self.addMenuCardSections( to: menu, model: model, provider: context.currentProvider, width: context.menuWidth, webItems: webItems) return true } menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth)) if context.currentProvider == .codex, model.creditsText != nil { menu.addItem(self.makeBuyCreditsItem()) } menu.addItem(.separator()) return false } private func addOpenAIWebItemsIfNeeded( to menu: NSMenu, currentProvider: UsageProvider, context: OpenAIWebContext, addedOpenAIWebItems: Bool) { guard context.hasOpenAIWebMenuItems else { return } if !addedOpenAIWebItems { // Only show these when we actually have additional data. if context.hasUsageBreakdown { _ = self.addUsageBreakdownSubmenu(to: menu) } if context.hasCreditsHistory { _ = self.addCreditsHistorySubmenu(to: menu) } if context.hasCostHistory { _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) } } menu.addItem(.separator()) } private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } return false } } for (index, section) in actionableSections.enumerated() { for entry in section.entries { switch entry { case let .text(text, style): if style == .secondary { menu.addItem(self.makeWrappedSecondaryTextItem(text: text, width: width)) continue } let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") item.isEnabled = false if style == .headline { let font = NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) item.attributedTitle = NSAttributedString(string: text, attributes: [.font: font]) } else if style == .secondary { let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) item.attributedTitle = NSAttributedString( string: text, attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]) } menu.addItem(item) case let .action(title, action): let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented if let iconName = action.systemImageName, let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { image.isTemplate = true image.size = NSSize(width: 16, height: 16) item.image = image } if case let .switchAccount(targetProvider) = action, let subtitle = self.switchAccountSubtitle(for: targetProvider) { item.isEnabled = false self.applySubtitle(subtitle, to: item, title: title) } menu.addItem(item) case .divider: menu.addItem(.separator()) } } if index < actionableSections.count - 1 { menu.addItem(.separator()) } } } private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) let height = self.menuTextItemHeight(for: view, width: width) view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) item.view = view item.isEnabled = false item.toolTip = text return item } private func makeWrappedSecondaryTextView(text: String) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let textField = NSTextField(wrappingLabelWithString: text) textField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) textField.textColor = NSColor.secondaryLabelColor textField.lineBreakMode = .byWordWrapping textField.maximumNumberOfLines = 0 textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textField.translatesAutoresizingMaskIntoConstraints = false container.addSubview(textField) NSLayoutConstraint.activate([ textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), textField.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), textField.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), ]) return container } private func menuTextItemHeight(for view: NSView, width: CGFloat) -> CGFloat { view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) view.layoutSubtreeIfNeeded() return max(1, ceil(view.fittingSize.height)) } func makeMenu(for provider: UsageProvider?) -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false menu.delegate = self if let provider { self.menuProviders[ObjectIdentifier(menu)] = provider } return menu } private func makeProviderSwitcherItem( providers: [UsageProvider], includesOverview: Bool, selected: ProviderSwitcherSelection, menu: NSMenu) -> NSMenuItem { let view = ProviderSwitcherView( providers: providers, selected: selected, includesOverview: includesOverview, width: self.menuCardWidth(for: providers, menu: menu), showsIcons: self.settings.switcherShowsIcons, iconProvider: { [weak self] provider in self?.switcherIcon(for: provider) ?? NSImage() }, weeklyRemainingProvider: { [weak self] provider in self?.switcherWeeklyRemaining(for: provider) }, onSelect: { [weak self, weak menu] selection in guard let self, let menu else { return } switch selection { case .overview: self.settings.mergedMenuLastSelectedWasOverview = true self.lastMergedSwitcherSelection = .overview let provider = self.resolvedMenuProvider() self.lastMenuProvider = provider ?? .codex self.populateMenu(menu, provider: provider) case let .provider(provider): self.settings.mergedMenuLastSelectedWasOverview = false self.lastMergedSwitcherSelection = .provider(provider) self.selectedMenuProvider = provider self.lastMenuProvider = provider self.populateMenu(menu, provider: provider) } self.markMenuFresh(menu) self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view item.isEnabled = false return item } private func makeTokenAccountSwitcherItem( display: TokenAccountMenuDisplay, menu: NSMenu) -> NSMenuItem { let view = TokenAccountSwitcherView( accounts: display.accounts, selectedIndex: display.activeIndex, width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() } } self.populateMenu(menu, provider: display.provider) self.markMenuFresh(menu) self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view item.isEnabled = false return item } private func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { let enabled = enabledProviders ?? self.store.enabledProvidersForDisplay() if enabled.isEmpty { return .codex } if let selected = self.selectedMenuProvider, enabled.contains(selected) { return selected } // Prefer an available provider so the default menu content matches the status icon. // Falls back to first display provider when all lack credentials. return enabled.first(where: { self.store.isProviderAvailable($0) }) ?? enabled.first } private func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { !self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders).isEmpty } private func resolvedSwitcherSelection( enabledProviders: [UsageProvider], includesOverview: Bool) -> ProviderSwitcherSelection { if includesOverview, self.settings.mergedMenuLastSelectedWasOverview { return .overview } return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) } private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 let showAll = self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( provider: provider, accounts: accounts, snapshots: snapshots, activeIndex: activeIndex, showAll: showAll, showSwitcher: !showAll) } private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion } private func markMenuFresh(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion } func refreshOpenMenusIfNeeded() { guard !self.openMenus.isEmpty else { return } for (key, menu) in self.openMenus { guard key == ObjectIdentifier(menu) else { // Clean up orphaned menu entries from all tracking dictionaries self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) continue } if self.isHostedSubviewMenu(menu) { self.refreshHostedSubviewHeights(in: menu) continue } if self.menuNeedsRefresh(menu) { let provider = self.menuProvider(for: menu) self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } } } private func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() } if let provider = self.menuProviders[ObjectIdentifier(menu)] { return provider } if menu === self.fallbackMenu { return nil } return self.store.enabledProvidersForDisplay().first ?? .codex } private func scheduleOpenMenuRefresh(for menu: NSMenu) { // Kick off a user-initiated refresh on open (non-forced) and re-check after a delay. // NEVER block menu opening with network requests. if !self.store.isRefreshing { self.refreshStore(forceTokenUsage: false) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() self.menuRefreshTasks[key] = Task { @MainActor [weak self, weak menu] in guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } guard self.menuNeedsDelayedRefreshRetry(for: menu) else { return } self.refreshStore(forceTokenUsage: false) } } private func menuNeedsDelayedRefreshRetry(for menu: NSMenu) -> Bool { let providersToCheck = self.delayedRefreshRetryProviders(for: menu) guard !providersToCheck.isEmpty else { return false } return providersToCheck.contains { provider in self.store.isStale(provider: provider) || self.store.snapshot(for: provider) == nil } } private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] { let enabledProviders = self.store.enabledProvidersForDisplay() guard !enabledProviders.isEmpty else { return [] } let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) if self.shouldMergeIcons, enabledProviders.count > 1, self.resolvedSwitcherSelection( enabledProviders: enabledProviders, includesOverview: includesOverview) == .overview { return self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders) } if let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider(enabledProviders: enabledProviders) { return [provider] } return enabledProviders } private func refreshMenuCardHeights(in menu: NSMenu) { // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content // changes (e.g. dashboard error lines causing wrapping). let cardItems = menu.items.filter { item in (item.representedObject as? String)?.hasPrefix("menuCard") == true } for item in cardItems { guard let view = item.view else { continue } let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) let height = self.menuCardHeight(for: view, width: width) view.frame = NSRect( origin: .zero, size: NSSize(width: width, height: height)) } } private func makeMenuCardItem( _ view: some View, id: String, width: CGFloat, submenu: NSMenu? = nil, onClick: (() -> Void)? = nil) -> NSMenuItem { if !Self.menuCardRenderingEnabled { let item = NSMenuItem() item.isEnabled = true item.representedObject = id item.submenu = submenu if submenu != nil { item.target = self item.action = #selector(self.menuCardNoOp(_:)) } return item } let highlightState = MenuCardHighlightState() let wrapped = MenuCardSectionContainerView( highlightState: highlightState, showsSubmenuIndicator: submenu != nil) { view } let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) // Set frame with target width immediately let height = self.menuCardHeight(for: hosting, width: width) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) let item = NSMenuItem() item.view = hosting item.isEnabled = true item.representedObject = id item.submenu = submenu if submenu != nil { item.target = self item.action = #selector(self.menuCardNoOp(_:)) } return item } private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { let basePadding: CGFloat = 6 let descenderSafety: CGFloat = 1 // Fast path: use protocol-based measurement when available (avoids layout passes) if let measured = view as? MenuCardMeasuring { return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) } // Set frame with target width before measuring. view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) // Use fittingSize directly - SwiftUI hosting views respect the frame width for wrapping let fitted = view.fittingSize return max(1, ceil(fitted.height + basePadding + descenderSafety)) } private func addMenuCardSections( to menu: NSMenu, model: UsageMenuCardView.Model, provider: UsageProvider, width: CGFloat, webItems: OpenAIWebMenuItems) { let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil let hasCredits = model.creditsText != nil let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil let bottomPadding = CGFloat(hasCredits ? 4 : 6) let sectionSpacing = CGFloat(6) let usageBottomPadding = bottomPadding let creditsBottomPadding = bottomPadding let headerView = UsageMenuCardHeaderSectionView( model: model, showDivider: hasUsageBlock, width: width) menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) if hasUsageBlock { let usageView = UsageMenuCardUsageSectionView( model: model, showBottomDivider: false, bottomPadding: usageBottomPadding, width: width) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), webItems: webItems) menu.addItem(self.makeMenuCardItem( usageView, id: "menuCardUsage", width: width, submenu: usageSubmenu)) } if hasCredits || hasExtraUsage || hasCost { menu.addItem(.separator()) } if hasCredits { if hasExtraUsage || hasCost { menu.addItem(.separator()) } let creditsView = UsageMenuCardCreditsSectionView( model: model, showBottomDivider: false, topPadding: sectionSpacing, bottomPadding: creditsBottomPadding, width: width) let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu() : nil menu.addItem(self.makeMenuCardItem( creditsView, id: "menuCardCredits", width: width, submenu: creditsSubmenu)) if provider == .codex { menu.addItem(self.makeBuyCreditsItem()) } } if hasExtraUsage { if hasCredits { menu.addItem(.separator()) } let extraUsageView = UsageMenuCardExtraUsageSectionView( model: model, topPadding: sectionSpacing, bottomPadding: bottomPadding, width: width) menu.addItem(self.makeMenuCardItem( extraUsageView, id: "menuCardExtraUsage", width: width)) } if hasCost { if hasCredits || hasExtraUsage { menu.addItem(.separator()) } let costView = UsageMenuCardCostSectionView( model: model, topPadding: sectionSpacing, bottomPadding: bottomPadding, width: width) let costSubmenu = webItems.hasCostHistory ? self.makeCostHistorySubmenu(provider: provider) : nil menu.addItem(self.makeMenuCardItem( costView, id: "menuCardCost", width: width, submenu: costSubmenu)) } } private func switcherIcon(for provider: UsageProvider) -> NSImage { if let brand = ProviderBrandIcon.image(for: provider) { return brand } // Fallback to the dynamic icon renderer if resources are missing (e.g. dev bundle mismatch). let snapshot = self.store.snapshot(for: provider) let showUsed = self.settings.usageBarsShowUsed let primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent if showUsed, provider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. weekly = 0 } if showUsed, provider == .warp, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, weekly == 0 { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = 0.0001 } let credits = provider == .codex ? self.store.credits?.remaining : nil let stale = self.store.isStale(provider: provider) let style = self.store.style(for: provider) let indicator = self.store.statusIndicator(for: provider) let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, creditsRemaining: credits, stale: stale, style: style, blink: 0, wiggle: 0, tilt: 0, statusIndicator: indicator) image.isTemplate = true return image } nonisolated static func switcherWeeklyMetricPercent( for provider: UsageProvider, snapshot: UsageSnapshot?, showUsed: Bool) -> Double? { let window = snapshot?.switcherWeeklyWindow(for: provider, showUsed: showUsed) guard let window else { return nil } return showUsed ? window.usedPercent : window.remainingPercent } private func switcherWeeklyRemaining(for provider: UsageProvider) -> Double? { Self.switcherWeeklyMetricPercent( for: provider, snapshot: self.store.snapshot(for: provider), showUsed: self.settings.usageBarsShowUsed) } private func selector(for action: MenuDescriptor.MenuAction) -> (Selector, Any?) { switch action { case .installUpdate: (#selector(self.installUpdate), nil) case .refresh: (#selector(self.refreshNow), nil) case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil) case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .openTerminal(command): (#selector(self.openTerminalCommand(_:)), command) case let .loginToProvider(url): (#selector(self.openLoginToProvider(_:)), url) case .settings: (#selector(self.showSettingsGeneral), nil) case .about: (#selector(self.showSettingsAbout), nil) case .quit: (#selector(self.quit), nil) case let .copyError(message): (#selector(self.copyError(_:)), message) } } @MainActor private protocol MenuCardHighlighting: AnyObject { func setHighlighted(_ highlighted: Bool) } @MainActor private protocol MenuCardMeasuring: AnyObject { func measuredHeight(width: CGFloat) -> CGFloat } @MainActor @Observable fileprivate final class MenuCardHighlightState { var isHighlighted = false } private final class MenuHostingView: NSHostingView { override var allowsVibrancy: Bool { true } } @MainActor private final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { private let highlightState: MenuCardHighlightState private let onClick: (() -> Void)? override var allowsVibrancy: Bool { true } override var intrinsicContentSize: NSSize { let size = super.intrinsicContentSize guard self.frame.width > 0 else { return size } return NSSize(width: self.frame.width, height: size.height) } init(rootView: Content, highlightState: MenuCardHighlightState, onClick: (() -> Void)? = nil) { self.highlightState = highlightState self.onClick = onClick super.init(rootView: rootView) if onClick != nil { let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) recognizer.buttonMask = 0x1 self.addGestureRecognizer(recognizer) } } required init(rootView: Content) { self.highlightState = MenuCardHighlightState() self.onClick = nil super.init(rootView: rootView) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { guard recognizer.state == .ended else { return } self.onClick?() } func measuredHeight(width: CGFloat) -> CGFloat { let controller = NSHostingController(rootView: self.rootView) let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) return measured.height } func setHighlighted(_ highlighted: Bool) { guard self.highlightState.isHighlighted != highlighted else { return } self.highlightState.isHighlighted = highlighted } } private struct MenuCardSectionContainerView: View { @Bindable var highlightState: MenuCardHighlightState let showsSubmenuIndicator: Bool let content: Content init( highlightState: MenuCardHighlightState, showsSubmenuIndicator: Bool, @ViewBuilder content: () -> Content) { self.highlightState = highlightState self.showsSubmenuIndicator = showsSubmenuIndicator self.content = content() } var body: some View { self.content .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) .foregroundStyle(MenuHighlightStyle.primary(self.highlightState.isHighlighted)) .background(alignment: .topLeading) { if self.highlightState.isHighlighted { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(MenuHighlightStyle.selectionBackground(true)) .padding(.horizontal, 6) .padding(.vertical, 2) } } .overlay(alignment: .topTrailing) { if self.showsSubmenuIndicator { Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.highlightState.isHighlighted)) .padding(.top, 8) .padding(.trailing, 10) } } } } private func makeBuyCreditsItem() -> NSMenuItem { let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true image.size = NSSize(width: 16, height: 16) item.image = image } return item } @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) return true } @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) return true } @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) return true } private func makeUsageSubmenu( provider: UsageProvider, snapshot: UsageSnapshot?, webItems: OpenAIWebMenuItems) -> NSMenu? { if provider == .codex, webItems.hasUsageBreakdown { return self.makeUsageBreakdownSubmenu() } if provider == .zai { return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) } return nil } private func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } guard !timeLimit.usageDetails.isEmpty else { return nil } let submenu = NSMenu() submenu.delegate = self let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "") titleItem.isEnabled = false submenu.addItem(titleItem) if let window = timeLimit.windowLabel { let item = NSMenuItem(title: "Window: \(window)", action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } if let resetTime = timeLimit.nextResetTime { let reset = self.settings.resetTimeDisplayStyle == .absolute ? UsageFormatter.resetDescription(from: resetTime) : UsageFormatter.resetCountdownDescription(from: resetTime) let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } submenu.addItem(.separator()) let sortedDetails = timeLimit.usageDetails.sorted { $0.modelCode.localizedCaseInsensitiveCompare($1.modelCode) == .orderedAscending } for detail in sortedDetails { let usage = UsageFormatter.tokenCountString(detail.usage) let item = NSMenuItem(title: "\(detail.modelCode): \(usage)", action: nil, keyEquivalent: "") submenu.addItem(item) } return submenu } private func makeUsageBreakdownSubmenu() -> NSMenu? { let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] let width = Self.menuCardBaseWidth guard !breakdown.isEmpty else { return nil } if !Self.menuCardRenderingEnabled { let submenu = NSMenu() submenu.delegate = self let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) return submenu } let submenu = NSMenu() submenu.delegate = self let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) return submenu } private func makeCreditsHistorySubmenu() -> NSMenu? { let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] let width = Self.menuCardBaseWidth guard !breakdown.isEmpty else { return nil } if !Self.menuCardRenderingEnabled { let submenu = NSMenu() submenu.delegate = self let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = "creditsHistoryChart" submenu.addItem(chartItem) return submenu } let submenu = NSMenu() submenu.delegate = self let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false chartItem.representedObject = "creditsHistoryChart" submenu.addItem(chartItem) return submenu } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } let width = Self.menuCardBaseWidth guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } guard !tokenSnapshot.daily.isEmpty else { return nil } if !Self.menuCardRenderingEnabled { let submenu = NSMenu() submenu.delegate = self let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = "costHistoryChart" submenu.addItem(chartItem) return submenu } let submenu = NSMenu() submenu.delegate = self let chartView = CostHistoryChartMenuView( provider: provider, daily: tokenSnapshot.daily, totalCostUSD: tokenSnapshot.last30DaysCostUSD, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false chartItem.representedObject = "costHistoryChart" submenu.addItem(chartItem) return submenu } private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ "usageBreakdownChart", "creditsHistoryChart", "costHistoryChart", ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } return ids.contains(id) } } private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ "usageBreakdownChart", "creditsHistoryChart", ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } return ids.contains(id) } } private func refreshHostedSubviewHeights(in menu: NSMenu) { let enabledProviders = self.store.enabledProvidersForDisplay() let width = self.menuCardWidth(for: enabledProviders, menu: menu) for item in menu.items { guard let view = item.view else { continue } view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) view.layoutSubtreeIfNeeded() let height = view.fittingSize.height view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) } } private func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, errorOverride: String? = nil) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) let snapshot = snapshotOverride ?? self.store.snapshot(for: target) let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? if target == .codex, snapshotOverride == nil { credits = self.store.credits creditsError = self.store.lastCreditsError dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) } else if target == .claude || target == .vertexai, snapshotOverride == nil { credits = nil creditsError = nil dashboard = nil dashboardError = nil tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) } else { credits = nil creditsError = nil dashboard = nil dashboardError = nil tokenSnapshot = nil tokenError = nil } let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto let now = Date() let weeklyPace = snapshot?.secondary.flatMap { window in self.store.weeklyPace(provider: target, window: window, now: now) } let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, snapshot: snapshot, credits: credits, creditsError: creditsError, dashboard: dashboard, dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: self.account, isRefreshing: self.store.isRefreshing, lastError: errorOverride ?? self.store.error(for: target), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) } @objc private func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } @objc private func selectOverviewProvider(_ sender: NSMenuItem) { guard let represented = sender.representedObject as? String, represented.hasPrefix(Self.overviewRowIdentifierPrefix) else { return } let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) guard let provider = UsageProvider(rawValue: rawProvider), let menu = sender.menu else { return } self.selectOverviewProvider(provider, menu: menu) } private func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } self.settings.mergedMenuLastSelectedWasOverview = false self.lastMergedSwitcherSelection = nil self.selectedMenuProvider = provider self.lastMenuProvider = provider self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) self.applyIcon(phase: nil) } private func applySubtitle(_ subtitle: String, to item: NSMenuItem, title: String) { if #available(macOS 14.4, *) { // NSMenuItem.subtitle is only available on macOS 14.4+. item.subtitle = subtitle } else { item.view = self.makeMenuSubtitleView(title: title, subtitle: subtitle, isEnabled: item.isEnabled) item.toolTip = "\(title) — \(subtitle)" } } private func makeMenuSubtitleView(title: String, subtitle: String, isEnabled: Bool) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false container.alphaValue = isEnabled ? 1.0 : 0.7 let titleField = NSTextField(labelWithString: title) titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize) titleField.textColor = NSColor.labelColor titleField.lineBreakMode = .byTruncatingTail titleField.maximumNumberOfLines = 1 titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let subtitleField = NSTextField(labelWithString: subtitle) subtitleField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) subtitleField.textColor = NSColor.secondaryLabelColor subtitleField.lineBreakMode = .byTruncatingTail subtitleField.maximumNumberOfLines = 1 subtitleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let stack = NSStackView(views: [titleField, subtitleField]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 1 stack.translatesAutoresizingMaskIntoConstraints = false container.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), ]) return container } } ================================================ FILE: Sources/CodexBar/StatusItemController+SwitcherViews.swift ================================================ import AppKit import CodexBarCore enum ProviderSwitcherSelection: Equatable { case overview case provider(UsageProvider) } final class ProviderSwitcherView: NSView { private struct Segment { let selection: ProviderSwitcherSelection let image: NSImage let title: String } private struct WeeklyIndicator { let track: NSView let fill: NSView } private let segments: [Segment] private let onSelect: (ProviderSwitcherSelection) -> Void private let showsIcons: Bool private let weeklyRemainingProvider: (UsageProvider) -> Double? private var buttons: [NSButton] = [] private var weeklyIndicators: [ObjectIdentifier: WeeklyIndicator] = [:] private var hoverTrackingArea: NSTrackingArea? private var segmentWidths: [CGFloat] = [] private let selectedBackground = NSColor.controlAccentColor.cgColor private let unselectedBackground = NSColor.clear.cgColor private let selectedTextColor = NSColor.white private let unselectedTextColor = NSColor.secondaryLabelColor private let stackedIcons: Bool private let rowCount: Int private let rowSpacing: CGFloat private let rowHeight: CGFloat private var preferredWidth: CGFloat = 0 private var hoveredButtonTag: Int? private let lightModeOverlayLayer = CALayer() init( providers: [UsageProvider], selected: ProviderSwitcherSelection?, includesOverview: Bool, width: CGFloat, showsIcons: Bool, iconProvider: (UsageProvider) -> NSImage, weeklyRemainingProvider: @escaping (UsageProvider) -> Double?, onSelect: @escaping (ProviderSwitcherSelection) -> Void) { let minimumGap: CGFloat = 1 var segments = providers.map { provider in let fullTitle = Self.switcherTitle(for: provider) let icon = iconProvider(provider) icon.isTemplate = true // Avoid any resampling: we ship exact 16pt/32px assets for crisp rendering. icon.size = NSSize(width: 16, height: 16) return Segment( selection: .provider(provider), image: icon, title: fullTitle) } if includesOverview { let overviewIcon = Self.overviewIcon() overviewIcon.isTemplate = true overviewIcon.size = NSSize(width: 16, height: 16) segments.insert( Segment( selection: .overview, image: overviewIcon, title: "Overview"), at: 0) } self.segments = segments self.onSelect = onSelect self.showsIcons = showsIcons self.weeklyRemainingProvider = weeklyRemainingProvider self.stackedIcons = showsIcons && self.segments.count > 3 let initialOuterPadding = Self.switcherOuterPadding( for: width, count: self.segments.count, minimumGap: minimumGap) let initialMaxAllowedSegmentWidth = Self.maxAllowedUniformSegmentWidth( for: width, count: self.segments.count, outerPadding: initialOuterPadding, minimumGap: minimumGap) self.rowCount = Self.switcherRowCount( width: width, count: self.segments.count, maxAllowedSegmentWidth: initialMaxAllowedSegmentWidth, stackedIcons: self.stackedIcons) self.rowSpacing = self.stackedIcons ? 4 : 2 if self.stackedIcons && self.rowCount >= 3 { self.rowHeight = 40 } else { self.rowHeight = self.stackedIcons ? 36 : 30 } let height: CGFloat = self.rowHeight * CGFloat(self.rowCount) + self.rowSpacing * CGFloat(max(0, self.rowCount - 1)) self.preferredWidth = width super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) Self.clearButtonWidthCache() self.wantsLayer = true self.layer?.masksToBounds = false self.lightModeOverlayLayer.masksToBounds = false self.layer?.insertSublayer(self.lightModeOverlayLayer, at: 0) self.updateLightModeStyling() let layoutCount = Self.layoutCount(for: self.segments.count, rows: self.rowCount) let outerPadding: CGFloat = Self.switcherOuterPadding( for: width, count: layoutCount, minimumGap: minimumGap) let maxAllowedSegmentWidth = Self.maxAllowedUniformSegmentWidth( for: width, count: layoutCount, outerPadding: outerPadding, minimumGap: minimumGap) func makeButton(index: Int, segment: Segment) -> NSButton { let button: NSButton if self.stackedIcons { let stacked = StackedToggleButton( title: segment.title, image: segment.image, target: self, action: #selector(self.handleSelection(_:))) stacked.setAllowsTwoLineTitle(self.rowCount >= 3) if self.rowCount >= 4 { stacked.setTitleFontSize(NSFont.smallSystemFontSize - 3) } button = stacked } else if self.showsIcons { let inline = InlineIconToggleButton( title: segment.title, image: segment.image, target: self, action: #selector(self.handleSelection(_:))) button = inline } else { button = PaddedToggleButton( title: segment.title, target: self, action: #selector(self.handleSelection(_:))) } button.tag = index if self.showsIcons { if self.stackedIcons { // StackedToggleButton manages its own image view. } else { // InlineIconToggleButton manages its own image view. } } else { button.image = nil button.imagePosition = .noImage } let remaining: Double? = switch segment.selection { case let .provider(provider): self.weeklyRemainingProvider(provider) case .overview: nil } self.addWeeklyIndicator(to: button, selection: segment.selection, remainingPercent: remaining) button.bezelStyle = .regularSquare button.isBordered = false button.controlSize = .small button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) button.setButtonType(.toggle) button.contentTintColor = self.unselectedTextColor button.alignment = .center button.wantsLayer = true button.layer?.cornerRadius = 6 button.state = (selected == segment.selection) ? .on : .off button.toolTip = nil button.translatesAutoresizingMaskIntoConstraints = false self.buttons.append(button) return button } for (index, segment) in self.segments.enumerated() { let button = makeButton(index: index, segment: segment) self.addSubview(button) } let uniformWidth: CGFloat if self.rowCount > 1 || !self.stackedIcons { uniformWidth = self.applyUniformSegmentWidth(maxAllowedWidth: maxAllowedSegmentWidth) if uniformWidth > 0 { self.segmentWidths = Array(repeating: uniformWidth, count: self.buttons.count) } } else { self.segmentWidths = self.applyNonUniformSegmentWidths( totalWidth: width, outerPadding: outerPadding, minimumGap: minimumGap) uniformWidth = 0 } self.applyLayout( outerPadding: outerPadding, minimumGap: minimumGap, uniformWidth: uniformWidth) if width > 0 { self.preferredWidth = width self.frame.size.width = width } self.updateButtonStyles() } override func layout() { super.layout() self.lightModeOverlayLayer.frame = self.bounds } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() self.updateLightModeStyling() self.updateButtonStyles() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() self.window?.acceptsMouseMovedEvents = true } override func updateTrackingAreas() { super.updateTrackingAreas() if let hoverTrackingArea { self.removeTrackingArea(hoverTrackingArea) } let trackingArea = NSTrackingArea( rect: .zero, options: [ .activeAlways, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved, ], owner: self, userInfo: nil) self.addTrackingArea(trackingArea) self.hoverTrackingArea = trackingArea } override func mouseMoved(with event: NSEvent) { let location = self.convert(event.locationInWindow, from: nil) let hoveredTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag guard hoveredTag != self.hoveredButtonTag else { return } self.hoveredButtonTag = hoveredTag self.updateButtonStyles() } override func mouseExited(with event: NSEvent) { guard self.hoveredButtonTag != nil else { return } self.hoveredButtonTag = nil self.updateButtonStyles() } private func applyLayout( outerPadding: CGFloat, minimumGap: CGFloat, uniformWidth: CGFloat) { if self.rowCount > 1 { self.applyMultiRowLayout( rowCount: self.rowCount, outerPadding: outerPadding, minimumGap: minimumGap, uniformWidth: uniformWidth) return } if self.buttons.count == 2 { let left = self.buttons[0] let right = self.buttons[1] let gap = right.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap) gap.priority = .defaultHigh NSLayoutConstraint.activate([ left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), left.centerYAnchor.constraint(equalTo: self.centerYAnchor), right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), right.centerYAnchor.constraint(equalTo: self.centerYAnchor), gap, ]) return } if self.buttons.count == 3 { let left = self.buttons[0] let mid = self.buttons[1] let right = self.buttons[2] let leftGap = mid.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap) leftGap.priority = .defaultHigh let rightGap = right.leadingAnchor.constraint( greaterThanOrEqualTo: mid.trailingAnchor, constant: minimumGap) rightGap.priority = .defaultHigh NSLayoutConstraint.activate([ left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), left.centerYAnchor.constraint(equalTo: self.centerYAnchor), mid.centerXAnchor.constraint(equalTo: self.centerXAnchor), mid.centerYAnchor.constraint(equalTo: self.centerYAnchor), right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), right.centerYAnchor.constraint(equalTo: self.centerYAnchor), leftGap, rightGap, ]) return } if self.buttons.count >= 4 { let widths = self.segmentWidths.isEmpty ? self.buttons.map { ceil($0.fittingSize.width) } : self.segmentWidths let layoutWidth = self.preferredWidth > 0 ? self.preferredWidth : self.bounds.width let availableWidth = max(0, layoutWidth - outerPadding * 2) let gaps = max(1, widths.count - 1) let computedGap = gaps > 0 ? max(minimumGap, (availableWidth - widths.reduce(0, +)) / CGFloat(gaps)) : 0 let rowContainer = NSView() rowContainer.translatesAutoresizingMaskIntoConstraints = false self.addSubview(rowContainer) NSLayoutConstraint.activate([ rowContainer.topAnchor.constraint(equalTo: self.topAnchor), rowContainer.bottomAnchor.constraint(equalTo: self.bottomAnchor), rowContainer.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), rowContainer.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), ]) var xOffset: CGFloat = 0 for (index, button) in self.buttons.enumerated() { let width = index < widths.count ? widths[index] : 0 if self.stackedIcons { NSLayoutConstraint.activate([ button.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: xOffset), button.topAnchor.constraint(equalTo: rowContainer.topAnchor), ]) } else { NSLayoutConstraint.activate([ button.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: xOffset), button.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), ]) } xOffset += width + computedGap } return } if let first = self.buttons.first { NSLayoutConstraint.activate([ first.centerXAnchor.constraint(equalTo: self.centerXAnchor), first.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } } private func applyMultiRowLayout( rowCount: Int, outerPadding: CGFloat, minimumGap: CGFloat, uniformWidth: CGFloat) { let rows = Self.splitRows(for: self.buttons, rowCount: rowCount) let columns = rows.map(\.count).max() ?? 0 let layoutWidth = self.preferredWidth > 0 ? self.preferredWidth : self.bounds.width let availableWidth = max(0, layoutWidth - outerPadding * 2) let gaps = max(1, columns - 1) let totalWidth = uniformWidth * CGFloat(columns) let computedGap = gaps > 0 ? max(minimumGap, (availableWidth - totalWidth) / CGFloat(gaps)) : 0 let gridContainer = NSView() gridContainer.translatesAutoresizingMaskIntoConstraints = false self.addSubview(gridContainer) NSLayoutConstraint.activate([ gridContainer.topAnchor.constraint(equalTo: self.topAnchor), gridContainer.bottomAnchor.constraint(equalTo: self.bottomAnchor), gridContainer.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), gridContainer.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), ]) var rowViews: [NSView] = [] for _ in 0.. Int { guard count > 1 else { return 1 } let maxRows = min(4, count) let fourRowThreshold = 15 let minimumComfortableAverage: CGFloat = stackedIcons ? 50 : 54 if count >= fourRowThreshold { return maxRows } if maxAllowedSegmentWidth >= minimumComfortableAverage { return 1 } for rows in 2...maxRows { let perRow = self.layoutCount(for: count, rows: rows) let outerPadding = self.switcherOuterPadding(for: width, count: perRow, minimumGap: 1) let allowedWidth = self.maxAllowedUniformSegmentWidth( for: width, count: perRow, outerPadding: outerPadding, minimumGap: 1) if allowedWidth >= minimumComfortableAverage { return rows } } return maxRows } private static func layoutCount(for count: Int, rows: Int) -> Int { guard rows > 0 else { return count } return Int(ceil(Double(count) / Double(rows))) } private static func splitRows(for buttons: [NSButton], rowCount: Int) -> [[NSButton]] { guard rowCount > 1 else { return [buttons] } let base = buttons.count / rowCount let extra = buttons.count % rowCount var rows: [[NSButton]] = [] var start = 0 for index in 0.. CGFloat { // Align with the card's left/right content grid when possible. let preferred: CGFloat = 16 let reduced: CGFloat = 10 let minimal: CGFloat = 6 func averageButtonWidth(outerPadding: CGFloat) -> CGFloat { let available = width - outerPadding * 2 - minimumGap * CGFloat(max(0, count - 1)) guard count > 0 else { return 0 } return available / CGFloat(count) } // Only sacrifice padding when we'd otherwise squeeze buttons into unreadable widths. let minimumComfortableAverage: CGFloat = count >= 5 ? 50 : 54 if averageButtonWidth(outerPadding: preferred) >= minimumComfortableAverage { return preferred } if averageButtonWidth(outerPadding: reduced) >= minimumComfortableAverage { return reduced } return minimal } @available(*, unavailable) required init?(coder: NSCoder) { nil } override var intrinsicContentSize: NSSize { NSSize(width: self.preferredWidth, height: self.frame.size.height) } @objc private func handleSelection(_ sender: NSButton) { let index = sender.tag guard self.segments.indices.contains(index) else { return } for (idx, button) in self.buttons.enumerated() { button.state = (idx == index) ? .on : .off } self.updateButtonStyles() self.onSelect(self.segments[index].selection) } private func updateButtonStyles() { for button in self.buttons { let isSelected = button.state == .on let isHovered = self.hoveredButtonTag == button.tag button.contentTintColor = isSelected ? self.selectedTextColor : self.unselectedTextColor button.layer?.backgroundColor = if isSelected { self.selectedBackground } else if isHovered { self.hoverPlateColor() } else { self.unselectedBackground } self.updateWeeklyIndicatorVisibility(for: button) (button as? StackedToggleButton)?.setContentTintColor(button.contentTintColor) (button as? InlineIconToggleButton)?.setContentTintColor(button.contentTintColor) } } private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } private func updateLightModeStyling() { guard self.isLightMode() else { self.lightModeOverlayLayer.backgroundColor = nil return } // The menu card background is very bright in light mode; add a subtle neutral wash to ground the switcher. self.lightModeOverlayLayer.backgroundColor = NSColor.black.withAlphaComponent(0.035).cgColor } private func hoverPlateColor() -> CGColor { if self.isLightMode() { return NSColor.black.withAlphaComponent(0.095).cgColor } return NSColor.labelColor.withAlphaComponent(0.06).cgColor } /// Cache for button width measurements to avoid repeated layout passes. private static var buttonWidthCache: [ObjectIdentifier: CGFloat] = [:] private static func maxToggleWidth(for button: NSButton) -> CGFloat { let buttonId = ObjectIdentifier(button) // Return cached value if available. if let cached = buttonWidthCache[buttonId] { return cached } let originalState = button.state defer { button.state = originalState } button.state = .off button.layoutSubtreeIfNeeded() let offWidth = button.fittingSize.width button.state = .on button.layoutSubtreeIfNeeded() let onWidth = button.fittingSize.width let maxWidth = max(offWidth, onWidth) self.buttonWidthCache[buttonId] = maxWidth return maxWidth } private static func clearButtonWidthCache() { self.buttonWidthCache.removeAll() } private func applyUniformSegmentWidth(maxAllowedWidth: CGFloat) -> CGFloat { guard !self.buttons.isEmpty else { return 0 } var desiredWidths: [CGFloat] = [] desiredWidths.reserveCapacity(self.buttons.count) for (index, button) in self.buttons.enumerated() { if self.stackedIcons, self.segments.indices.contains(index) { let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) let titleWidth = ceil( (self.segments[index].title as NSString).size(withAttributes: [.font: font]) .width) let contentPadding: CGFloat = 4 + 4 let extraSlack: CGFloat = 1 desiredWidths.append(ceil(titleWidth + contentPadding + extraSlack)) } else { desiredWidths.append(ceil(Self.maxToggleWidth(for: button))) } } let maxDesired = desiredWidths.max() ?? 0 let evenMaxDesired = maxDesired.truncatingRemainder(dividingBy: 2) == 0 ? maxDesired : maxDesired + 1 let evenMaxAllowed = maxAllowedWidth > 0 ? (maxAllowedWidth.truncatingRemainder(dividingBy: 2) == 0 ? maxAllowedWidth : maxAllowedWidth - 1) : 0 let finalWidth: CGFloat = if evenMaxAllowed > 0 { min(evenMaxDesired, evenMaxAllowed) } else { evenMaxDesired } if finalWidth > 0 { for button in self.buttons { button.widthAnchor.constraint(equalToConstant: finalWidth).isActive = true } } return finalWidth } @discardableResult private func applyNonUniformSegmentWidths( totalWidth: CGFloat, outerPadding: CGFloat, minimumGap: CGFloat) -> [CGFloat] { guard !self.buttons.isEmpty else { return [] } let count = self.buttons.count let available = totalWidth - outerPadding * 2 - minimumGap * CGFloat(max(0, count - 1)) guard available > 0 else { return [] } func evenFloor(_ value: CGFloat) -> CGFloat { var v = floor(value) if Int(v) % 2 != 0 { v -= 1 } return v } let desired = self.buttons.map { ceil(Self.maxToggleWidth(for: $0)) } let desiredSum = desired.reduce(0, +) let avg = floor(available / CGFloat(count)) let minWidth = max(24, min(40, avg)) var widths: [CGFloat] if desiredSum <= available { widths = desired } else { let totalCapacity = max(0, desiredSum - minWidth * CGFloat(count)) if totalCapacity <= 0 { widths = Array(repeating: available / CGFloat(count), count: count) } else { let overflow = desiredSum - available widths = desired.map { desiredWidth in let capacity = max(0, desiredWidth - minWidth) let shrink = overflow * (capacity / totalCapacity) return desiredWidth - shrink } } } widths = widths.map { max(minWidth, evenFloor($0)) } var used = widths.reduce(0, +) while available - used >= 2 { if let best = widths.indices .filter({ desired[$0] - widths[$0] >= 2 }) .max(by: { lhs, rhs in (desired[lhs] - widths[lhs]) < (desired[rhs] - widths[rhs]) }) { widths[best] += 2 used += 2 continue } guard let best = widths.indices.min(by: { lhs, rhs in widths[lhs] < widths[rhs] }) else { break } widths[best] += 2 used += 2 } for (index, button) in self.buttons.enumerated() where index < widths.count { button.widthAnchor.constraint(equalToConstant: widths[index]).isActive = true } return widths } private static func maxAllowedUniformSegmentWidth( for totalWidth: CGFloat, count: Int, outerPadding: CGFloat, minimumGap: CGFloat) -> CGFloat { guard count > 0 else { return 0 } let available = totalWidth - outerPadding * 2 - minimumGap * CGFloat(max(0, count - 1)) guard available > 0 else { return 0 } return floor(available / CGFloat(count)) } private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage { let size = NSSize(width: image.size.width + leading, height: image.size.height) let newImage = NSImage(size: size) newImage.lockFocus() let y = (size.height - image.size.height) / 2 image.draw( at: NSPoint(x: leading, y: y), from: NSRect(origin: .zero, size: image.size), operation: .sourceOver, fraction: 1.0) newImage.unlockFocus() newImage.isTemplate = image.isTemplate return newImage } private func addWeeklyIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { guard let remainingPercent else { return } let track = NSView() track.wantsLayer = true track.layer?.backgroundColor = NSColor.tertiaryLabelColor.withAlphaComponent(0.22).cgColor track.layer?.cornerRadius = 2 track.layer?.masksToBounds = true track.translatesAutoresizingMaskIntoConstraints = false view.addSubview(track) let fill = NSView() fill.wantsLayer = true fill.layer?.backgroundColor = Self.weeklyIndicatorColor(for: selection).cgColor fill.layer?.cornerRadius = 2 fill.translatesAutoresizingMaskIntoConstraints = false track.addSubview(fill) let ratio = CGFloat(max(0, min(1, remainingPercent / 100))) NSLayoutConstraint.activate([ track.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6), track.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), track.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -1), track.heightAnchor.constraint(equalToConstant: 4), fill.leadingAnchor.constraint(equalTo: track.leadingAnchor), fill.topAnchor.constraint(equalTo: track.topAnchor), fill.bottomAnchor.constraint(equalTo: track.bottomAnchor), ]) fill.widthAnchor.constraint(equalTo: track.widthAnchor, multiplier: ratio).isActive = true self.weeklyIndicators[ObjectIdentifier(view)] = WeeklyIndicator(track: track, fill: fill) self.updateWeeklyIndicatorVisibility(for: view) } private func updateWeeklyIndicatorVisibility(for view: NSView) { guard let indicator = self.weeklyIndicators[ObjectIdentifier(view)] else { return } let isSelected = (view as? NSButton)?.state == .on indicator.track.isHidden = isSelected indicator.fill.isHidden = isSelected } private static func weeklyIndicatorColor(for selection: ProviderSwitcherSelection) -> NSColor { switch selection { case let .provider(provider): let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return NSColor(deviceRed: color.red, green: color.green, blue: color.blue, alpha: 1) case .overview: return NSColor.secondaryLabelColor } } private static func overviewIcon() -> NSImage { if let symbol = NSImage(systemSymbolName: "square.grid.2x2", accessibilityDescription: nil) { return symbol } return NSImage(size: NSSize(width: 16, height: 16)) } private static func switcherTitle(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName } } final class TokenAccountSwitcherView: NSView { private let accounts: [ProviderTokenAccount] private let onSelect: (Int) -> Void private var selectedIndex: Int private var buttons: [NSButton] = [] private let rowSpacing: CGFloat = 4 private let rowHeight: CGFloat = 26 private let selectedBackground = NSColor.controlAccentColor.cgColor private let unselectedBackground = NSColor.clear.cgColor private let selectedTextColor = NSColor.white private let unselectedTextColor = NSColor.secondaryLabelColor init(accounts: [ProviderTokenAccount], selectedIndex: Int, width: CGFloat, onSelect: @escaping (Int) -> Void) { self.accounts = accounts self.onSelect = onSelect self.selectedIndex = min(max(selectedIndex, 0), max(0, accounts.count - 1)) let useTwoRows = accounts.count > 3 let rows = useTwoRows ? 2 : 1 let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0) super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) self.wantsLayer = true self.buildButtons(useTwoRows: useTwoRows) self.updateButtonStyles() } @available(*, unavailable) required init?(coder: NSCoder) { nil } private func buildButtons(useTwoRows: Bool) { let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count let rows: [[ProviderTokenAccount]] = { if !useTwoRows { return [self.accounts] } let first = Array(self.accounts.prefix(perRow)) let second = Array(self.accounts.dropFirst(perRow)) return [first, second] }() let stack = NSStackView() stack.orientation = .vertical stack.alignment = .centerX stack.spacing = self.rowSpacing stack.translatesAutoresizingMaskIntoConstraints = false var globalIndex = 0 for rowAccounts in rows { let row = NSStackView() row.orientation = .horizontal row.alignment = .centerY row.distribution = .fillEqually row.spacing = self.rowSpacing row.translatesAutoresizingMaskIntoConstraints = false for account in rowAccounts { let button = PaddedToggleButton( title: account.displayName, target: self, action: #selector(self.handleSelect)) button.tag = globalIndex button.toolTip = account.displayName button.isBordered = false button.setButtonType(.toggle) button.controlSize = .small button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) button.wantsLayer = true button.layer?.cornerRadius = 6 row.addArrangedSubview(button) self.buttons.append(button) globalIndex += 1 } stack.addArrangedSubview(row) } self.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), stack.topAnchor.constraint(equalTo: self.topAnchor), stack.bottomAnchor.constraint(equalTo: self.bottomAnchor), stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(rows.count) + (useTwoRows ? self.rowSpacing : 0)), ]) } private func updateButtonStyles() { for (index, button) in self.buttons.enumerated() { let selected = index == self.selectedIndex button.state = selected ? .on : .off button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor } } @objc private func handleSelect(_ sender: NSButton) { let index = sender.tag guard index >= 0, index < self.accounts.count else { return } self.selectedIndex = index self.updateButtonStyles() self.onSelect(index) } } ================================================ FILE: Sources/CodexBar/StatusItemController.swift ================================================ import AppKit import CodexBarCore import Observation import QuartzCore import SwiftUI // MARK: - Status item controller (AppKit-hosted icons, SwiftUI popovers) @MainActor protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() } @MainActor final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests static var menuRefreshEnabled = !SettingsStore.isRunningTests typealias Factory = (UsageStore, SettingsStore, AccountInfo, UpdaterProviding, PreferencesSelection) -> StatusItemControlling static let defaultFactory: Factory = { store, settings, account, updater, selection in StatusItemController( store: store, settings: settings, account: account, updater: updater, preferencesSelection: selection) } static var factory: Factory = StatusItemController.defaultFactory let store: UsageStore let settings: SettingsStore let account: AccountInfo let updater: UpdaterProviding private let statusBar: NSStatusBar var statusItem: NSStatusItem var statusItems: [UsageProvider: NSStatusItem] = [:] var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] var blinkTask: Task? var loginTask: Task? { didSet { self.refreshMenusForLoginStateChange() } } var creditsPurchaseWindow: OpenAICreditsPurchaseWindowController? var activeLoginProvider: UsageProvider? { didSet { if oldValue != self.activeLoginProvider { self.refreshMenusForLoginStateChange() } } } var blinkStates: [UsageProvider: BlinkState] = [:] var blinkAmounts: [UsageProvider: CGFloat] = [:] var wiggleAmounts: [UsageProvider: CGFloat] = [:] var tiltAmounts: [UsageProvider: CGFloat] = [:] var blinkForceUntil: Date? var loginPhase: LoginPhase = .idle { didSet { if oldValue != self.loginPhase { self.refreshMenusForLoginStateChange() } } } let preferencesSelection: PreferencesSelection var animationDriver: DisplayLinkDriver? var animationPhase: Double = 0 var animationPattern: LoadingPattern = .knightRider private var lastConfigRevision: Int private var lastProviderOrder: [UsageProvider] private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool /// Tracks whether the merged-menu switcher was built with the Overview tab visible. /// Used to force switcher rebuilds when Overview availability toggles. var lastSwitcherIncludesOverview: Bool = false /// Tracks which providers the merged menu's switcher was built with, to detect when it needs full rebuild. var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? let loginLogger = CodexBarLog.logger(LogCategories.login) var selectedMenuProvider: UsageProvider? { get { self.settings.selectedMenuProvider } set { self.settings.selectedMenuProvider = newValue } } struct BlinkState { var nextBlink: Date var blinkStart: Date? var pendingSecondStart: Date? var effect: MotionEffect = .blink static func randomDelay() -> TimeInterval { Double.random(in: 3...12) } } enum MotionEffect { case blink case wiggle case tilt } enum LoginPhase { case idle case requesting case waitingBrowser } func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { switch self.settings.menuBarMetricPreference(for: provider) { case .primary: return snapshot?.primary ?? snapshot?.secondary case .secondary: return snapshot?.secondary ?? snapshot?.primary case .average: guard let primary = snapshot?.primary, let secondary = snapshot?.secondary else { return snapshot?.primary ?? snapshot?.secondary } let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) case .automatic: if provider == .factory || provider == .kimi { return snapshot?.secondary ?? snapshot?.primary } if provider == .copilot, let primary = snapshot?.primary, let secondary = snapshot?.secondary { // Copilot can expose chat + completions quotas; show the more constrained one by default. return primary.usedPercent >= secondary.usedPercent ? primary : secondary } return snapshot?.primary ?? snapshot?.secondary } } init( store: UsageStore, settings: SettingsStore, account: AccountInfo, updater: UpdaterProviding, preferencesSelection: PreferencesSelection, statusBar: NSStatusBar = .system) { if SettingsStore.isRunningTests { _ = NSApplication.shared } self.store = store self.settings = settings self.account = account self.updater = updater self.preferencesSelection = preferencesSelection self.lastConfigRevision = settings.configRevision self.lastProviderOrder = settings.providerOrder self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.statusBar = statusBar let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). item.button?.imageScaling = .scaleNone self.statusItem = item // Status items for individual providers are now created lazily in updateVisibility() super.init() self.wireBindings() self.updateIcons() self.updateVisibility() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), name: .codexbarDebugReplayAllAnimations, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugBlinkNotification), name: .codexbarDebugBlinkNow, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(self.handleProviderConfigDidChange), name: .codexbarProviderConfigDidChange, object: nil) } private func wireBindings() { self.observeStoreChanges() self.observeDebugForceAnimation() self.observeSettingsChanges() self.observeUpdaterChanges() } private func observeStoreChanges() { withObservationTracking { _ = self.store.menuObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() self.invalidateMenus() self.updateIcons() self.updateBlinkingState() } } } private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeDebugForceAnimation() self.updateVisibility() self.updateBlinkingState() } } } private func observeSettingsChanges() { withObservationTracking { _ = self.settings.menuObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() self.handleSettingsChange(reason: "observation") } } } func handleProviderConfigChange(reason: String) { self.handleSettingsChange(reason: "config:\(reason)") } @objc private func handleProviderConfigDidChange(_ notification: Notification) { let reason = notification.userInfo?["reason"] as? String ?? "unknown" if let source = notification.object as? SettingsStore, source !== self.settings { if let config = notification.userInfo?["config"] as? CodexBarConfig { self.settings.applyExternalConfig(config, reason: "external-\(reason)") } else { self.settings.reloadConfig(reason: "external-\(reason)") } } self.handleProviderConfigChange(reason: "notification:\(reason)") } private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeUpdaterChanges() self.invalidateMenus() } } } private func invalidateMenus() { self.menuContentVersion &+= 1 // Don't refresh menus while they're open - wait until they close and reopen // This prevents expensive rebuilds while user is navigating the menu guard self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded() Task { @MainActor in // AppKit can ignore menu mutations while tracking; retry on the next run loop. await Task.yield() guard self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded() } } private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { var shouldRefresh = false let revision = self.settings.configRevision if revision != self.lastConfigRevision { self.lastConfigRevision = revision shouldRefresh = true } let order = self.settings.providerOrder if order != self.lastProviderOrder { self.lastProviderOrder = order shouldRefresh = true } let mergeIcons = self.settings.mergeIcons if mergeIcons != self.lastMergeIcons { self.lastMergeIcons = mergeIcons shouldRefresh = true } let showsIcons = self.settings.switcherShowsIcons if showsIcons != self.lastSwitcherShowsIcons { self.lastSwitcherShowsIcons = showsIcons shouldRefresh = true } let usageBarsShowUsed = self.settings.usageBarsShowUsed if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed { self.lastObservedUsageBarsShowUsed = usageBarsShowUsed shouldRefresh = true } return shouldRefresh } private func handleSettingsChange(reason: String) { let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() if orderChanged || configChanged { self.rebuildProviderStatusItems() } self.updateVisibility() self.updateIcons() if shouldRefreshOpenMenus { self.refreshOpenMenusIfNeeded() } } private func updateIcons() { // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { self.applyIcon(phase: phase) self.attachMenus() } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) } self.attachMenus(fallback: self.fallbackProvider) } self.updateAnimationState() self.updateBlinkingState() } /// Lazily retrieves or creates a status item for the given provider func lazyStatusItem(for provider: UsageProvider) -> NSStatusItem { if let existing = self.statusItems[provider] { return existing } let item = self.statusBar.statusItem(withLength: NSStatusItem.variableLength) item.button?.imageScaling = .scaleNone self.statusItems[provider] = item return item } private func updateVisibility() { let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons if mergeIcons { self.statusItem.isVisible = anyEnabled || force for item in self.statusItems.values { item.isVisible = false } self.attachMenus() } else { self.statusItem.isVisible = false let fallback = self.fallbackProvider for provider in UsageProvider.allCases { let isEnabled = self.isEnabled(provider) let shouldBeVisible = isEnabled || fallback == provider || force if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true } else if let item = self.statusItems[provider] { item.isVisible = false } } self.attachMenus(fallback: fallback) } self.updateAnimationState() self.updateBlinkingState() } var fallbackProvider: UsageProvider? { // Intentionally uses availability-filtered list: fallback activates when no provider // can actually work, ensuring at least a codex icon is always visible. self.store.enabledProviders().isEmpty ? .codex : nil } func isEnabled(_ provider: UsageProvider) -> Bool { self.store.isEnabled(provider) } private func refreshMenusForLoginStateChange() { self.invalidateMenus() if self.shouldMergeIcons { self.attachMenus() } else { self.attachMenus(fallback: self.fallbackProvider) } } private func attachMenus() { if self.mergedMenu == nil { self.mergedMenu = self.makeMenu() } if self.statusItem.menu !== self.mergedMenu { self.statusItem.menu = self.mergedMenu } } private func attachMenus(fallback: UsageProvider? = nil) { for provider in UsageProvider.allCases { // Only access/create the status item if it's actually needed let shouldHaveItem = self.isEnabled(provider) || fallback == provider if shouldHaveItem { let item = self.lazyStatusItem(for: provider) if self.isEnabled(provider) { if self.providerMenus[provider] == nil { self.providerMenus[provider] = self.makeMenu(for: provider) } let menu = self.providerMenus[provider] if item.menu !== menu { item.menu = menu } } else if fallback == provider { if self.fallbackMenu == nil { self.fallbackMenu = self.makeMenu(for: nil) } if item.menu !== self.fallbackMenu { item.menu = self.fallbackMenu } } } else if let item = self.statusItems[provider] { // Item exists but is no longer needed - clear its menu if item.menu != nil { item.menu = nil } } } } private func rebuildProviderStatusItems() { for item in self.statusItems.values { self.statusBar.removeStatusItem(item) } self.statusItems.removeAll(keepingCapacity: true) for provider in self.settings.orderedProviders() { let item = self.statusBar.statusItem(withLength: NSStatusItem.variableLength) item.button?.imageScaling = .scaleNone self.statusItems[provider] = item } } func isVisible(_ provider: UsageProvider) -> Bool { self.store.debugForceAnimation || self.isEnabled(provider) || self.fallbackProvider == provider } var shouldMergeIcons: Bool { self.settings.mergeIcons && self.store.enabledProvidersForDisplay().count > 1 } func switchAccountSubtitle(for target: UsageProvider) -> String? { guard self.loginTask != nil, let provider = self.activeLoginProvider, provider == target else { return nil } let base: String switch self.loginPhase { case .idle: return nil case .requesting: base = "Requesting login…" case .waitingBrowser: base = "Waiting in browser…" } let prefix = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName return "\(prefix): \(base)" } deinit { self.blinkTask?.cancel() self.loginTask?.cancel() NotificationCenter.default.removeObserver(self) } } ================================================ FILE: Sources/CodexBar/SyntheticTokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol SyntheticTokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum SyntheticTokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainSyntheticTokenStore: SyntheticTokenStoring { private static let log = CodexBarLog.logger(LogCategories.syntheticTokenStore) private let service = "com.steipete.CodexBar" private let account = "synthetic-api-key" func loadToken() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token load") return nil } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .syntheticToken, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw SyntheticTokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw SyntheticTokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) if let token, !token.isEmpty { return token } return nil } func storeToken(_ token: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token store") return } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw SyntheticTokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw SyntheticTokenStoreError.keychainStatus(addStatus) } } private func deleteTokenIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } Self.log.error("Keychain delete failed: \(status)") throw SyntheticTokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBar/UpdateChannel.swift ================================================ import Foundation enum UpdateChannel: String, CaseIterable, Codable { case stable case beta static let userDefaultsKey = "updateChannel" static let sparkleBetaChannel = "beta" var displayName: String { switch self { case .stable: "Stable" case .beta: "Beta" } } var description: String { switch self { case .stable: "Receive only stable, production-ready releases." case .beta: "Receive stable releases plus beta previews." } } var allowedSparkleChannels: Set { switch self { case .stable: [""] case .beta: ["", UpdateChannel.sparkleBetaChannel] } } static var current: Self { if let rawValue = UserDefaults.standard.string(forKey: userDefaultsKey), let channel = Self(rawValue: rawValue) { return channel } return defaultChannel } static var defaultChannel: Self { defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") } static func defaultChannel(for appVersion: String) -> Self { if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"), let isPrerelease = isPrereleaseValue as? Bool, isPrerelease { return .beta } let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"] let lowercaseVersion = appVersion.lowercased() for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) { return .beta } return .stable } } extension UpdateChannel: Identifiable { var id: String { rawValue } } ================================================ FILE: Sources/CodexBar/UsageBreakdownChartMenuView.swift ================================================ import Charts import CodexBarCore import SwiftUI @MainActor struct UsageBreakdownChartMenuView: View { private struct Point: Identifiable { let id: String let date: Date let service: String let creditsUsed: Double init(date: Date, service: String, creditsUsed: Double) { self.date = date self.service = service self.creditsUsed = creditsUsed self.id = "\(service)-\(Int(date.timeIntervalSince1970))- \(creditsUsed)" } } private let breakdown: [OpenAIDashboardDailyBreakdown] private let width: CGFloat @State private var selectedDayKey: String? init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) { self.breakdown = breakdown self.width = width } var body: some View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { Text("No usage breakdown data.") .font(.footnote) .foregroundStyle(.secondary) } else { Chart { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), y: .value("Credits used", point.creditsUsed)) .foregroundStyle(by: .value("Service", point.service)) } if let peak = model.peakPoint { let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), yEnd: .value("Cap end", peak.creditsUsed)) .foregroundStyle(Color(nsColor: .systemYellow)) } } .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) .chartYAxis(.hidden) .chartXAxis { AxisMarks(values: model.axisDates) { _ in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } .chartLegend(.hidden) .frame(height: 130) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { Rectangle() .fill(Self.selectionBandColor) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) } MouseLocationReader { location in self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } } } let detail = self.detailLines(model: model) VStack(alignment: .leading, spacing: 0) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) Text(detail.secondary ?? " ") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) .opacity(detail.secondary == nil ? 0 : 1) } LazyVGrid( columns: [GridItem(.adaptive(minimum: 110), alignment: .leading)], alignment: .leading, spacing: 6) { ForEach(model.services, id: \.self) { service in HStack(spacing: 6) { Circle() .fill(model.color(for: service)) .frame(width: 7, height: 7) Text(service) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } } } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] let dayDates: [(dayKey: String, date: Date)] let selectableDayDates: [(dayKey: String, date: Date)] let peakPoint: (date: Date, creditsUsed: Double)? let services: [String] let serviceColors: [Color] let axisDates: [Date] let maxCreditsUsed: Double func color(for service: String) -> Color { guard let idx = self.services.firstIndex(of: service), idx < self.serviceColors.count else { return .secondary } return self.serviceColors[idx] } } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = breakdown .sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] points.reserveCapacity(sorted.count * 2) var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:] breakdownByDayKey.reserveCapacity(sorted.count) var dayDates: [(dayKey: String, date: Date)] = [] dayDates.reserveCapacity(sorted.count) var selectableDayDates: [(dayKey: String, date: Date)] = [] selectableDayDates.reserveCapacity(sorted.count) var peak: (date: Date, creditsUsed: Double)? var maxCreditsUsed: Double = 0 for day in sorted { guard let date = self.dateFromDayKey(day.day) else { continue } breakdownByDayKey[day.day] = day dayDates.append((dayKey: day.day, date: date)) if day.totalCreditsUsed > 0 { if let cur = peak { if day.totalCreditsUsed > cur.creditsUsed { peak = (date, day.totalCreditsUsed) } } else { peak = (date, day.totalCreditsUsed) } maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed) } var addedSelectable = false for service in day.services where service.creditsUsed > 0 { points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed)) if !addedSelectable { selectableDayDates.append((dayKey: day.day, date: date)) addedSelectable = true } } } let services = Self.serviceOrder(from: sorted) let colors = services.map { Self.colorForService($0) } let axisDates = Self.axisDates(fromSortedDays: sorted) return Model( points: points, breakdownByDayKey: breakdownByDayKey, dayDates: dayDates, selectableDayDates: selectableDayDates, peakPoint: peak, services: services, serviceColors: colors, axisDates: axisDates, maxCreditsUsed: maxCreditsUsed) } private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func serviceOrder(from breakdown: [OpenAIDashboardDailyBreakdown]) -> [String] { var totals: [String: Double] = [:] for day in breakdown { for service in day.services { totals[service.service, default: 0] += service.creditsUsed } } return totals .sorted { lhs, rhs in if lhs.value == rhs.value { return lhs.key < rhs.key } return lhs.value > rhs.value } .map(\.key) } private static func colorForService(_ service: String) -> Color { let lower = service.lowercased() if lower == "cli" { return Color(red: 0.26, green: 0.55, blue: 0.96) } if lower.contains("github"), lower.contains("review") { return Color(red: 0.94, green: 0.53, blue: 0.18) } let palette: [Color] = [ Color(red: 0.46, green: 0.75, blue: 0.36), Color(red: 0.80, green: 0.45, blue: 0.92), Color(red: 0.26, green: 0.78, blue: 0.86), Color(red: 0.94, green: 0.74, blue: 0.26), ] let idx = abs(service.hashValue) % palette.count return palette[idx] } private static func axisDates(fromSortedDays sortedDays: [OpenAIDashboardDailyBreakdown]) -> [Date] { guard let first = sortedDays.first, let last = sortedDays.last else { return [] } guard let firstDate = self.dateFromDayKey(first.day), let lastDate = self.dateFromDayKey(last.day) else { return [] } if Calendar.current.isDate(firstDate, inSameDayAs: lastDate) { return [firstDate] } return [firstDate, lastDate] } private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = year comps.month = month comps.day = day // Noon avoids off-by-one-day shifts if anything ends up interpreted in UTC. comps.hour = 12 return comps.date } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDayKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil } let date = model.dayDates[index].date guard let x = proxy.position(forX: date) else { return nil } func xForIndex(_ idx: Int) -> CGFloat? { guard idx >= 0, idx < model.dayDates.count else { return nil } return proxy.position(forX: model.dayDates[idx].date) } let xPrev = xForIndex(index - 1) let xNext = xForIndex(index + 1) if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, y: plotFrame.origin.y, width: plotFrame.width, height: plotFrame.height) } let leftInPlot: CGFloat = if let xPrev { (xPrev + x) / 2 } else if let xNext { x - (xNext - x) / 2 } else { x - 8 } let rightInPlot: CGFloat = if let xNext { (xNext + x) / 2 } else if let xPrev { x + (x - xPrev) / 2 } else { x + 8 } let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } private func updateSelection( location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { guard let location else { if self.selectedDayKey != nil { self.selectedDayKey = nil } return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] guard plotFrame.contains(location) else { return } let xInPlot = location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDayKey(to: date, model: model) else { return } if self.selectedDayKey != nearest { self.selectedDayKey = nearest } } private func nearestDayKey(to date: Date, model: Model) -> String? { guard !model.selectableDayDates.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.selectableDayDates { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { if dist < cur.distance { best = (entry.dayKey, dist) } } else { best = (entry.dayKey, dist) } } return best?.key } private func detailLines(model: Model) -> (primary: String, secondary: String?) { guard let key = self.selectedDayKey, let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { return ("Hover a bar for details", nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { return ("\(dayLabel): \(total)", nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) return ("\(dayLabel): \(used)", first.service) } let services = day.services .sorted { lhs, rhs in if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service } return lhs.creditsUsed > rhs.creditsUsed } .prefix(3) .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") return ("\(dayLabel): \(total)", services) } } ================================================ FILE: Sources/CodexBar/UsagePaceText.swift ================================================ import CodexBarCore import Foundation enum UsagePaceText { struct WeeklyDetail { let leftLabel: String let rightLabel: String? let expectedUsedPercent: Double let stage: UsagePace.Stage } static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { return "Pace: \(detail.leftLabel) · \(rightLabel)" } return "Pace: \(detail.leftLabel)" } static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { WeeklyDetail( leftLabel: self.detailLeftLabel(for: pace), rightLabel: self.detailRightLabel(for: pace, now: now), expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } private static func detailLeftLabel(for pace: UsagePace) -> String { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: return "On pace" case .slightlyAhead, .ahead, .farAhead: return "\(deltaValue)% in deficit" case .slightlyBehind, .behind, .farBehind: return "\(deltaValue)% in reserve" } } private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { etaLabel = "Lasts until reset" } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)" } else { etaLabel = nil } guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) let riskLabel = "≈ \(roundedRisk)% run-out risk" if let etaLabel { return "\(etaLabel) · \(riskLabel)" } return riskLabel } private static func durationText(seconds: TimeInterval, now: Date) -> String { let date = now.addingTimeInterval(seconds) let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) if countdown == "now" { return "now" } if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } return countdown } private static func roundedRiskPercent(_ probability: Double) -> Int { let percent = probability.clamped(to: 0...1) * 100 let rounded = (percent / 5).rounded() * 5 return Int(rounded) } } ================================================ FILE: Sources/CodexBar/UsageProgressBar.swift ================================================ import SwiftUI /// Static progress fill with no implicit animations, used inside the menu card. struct UsageProgressBar: View { private static let paceStripeCount = 3 private static func paceStripeWidth(for scale: CGFloat) -> CGFloat { 2 } private static func paceStripeSpan(for scale: CGFloat) -> CGFloat { let stripeCount = max(1, Self.paceStripeCount) return Self.paceStripeWidth(for: scale) * CGFloat(stripeCount) } let percent: Double let tint: Color let accessibilityLabel: String let pacePercent: Double? let paceOnTop: Bool @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale init( percent: Double, tint: Color, accessibilityLabel: String, pacePercent: Double? = nil, paceOnTop: Bool = true) { self.percent = percent self.tint = tint self.accessibilityLabel = accessibilityLabel self.pacePercent = pacePercent self.paceOnTop = paceOnTop } private var clamped: Double { min(100, max(0, self.percent)) } var body: some View { GeometryReader { proxy in let scale = max(self.displayScale, 1) let fillWidth = proxy.size.width * self.clamped / 100 let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100 let tipWidth = max(25, proxy.size.height * 6.5) let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 let needsPunchCompositing = showTip let bar = ZStack(alignment: .leading) { Capsule() .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) self.actualBar(width: fillWidth) if showTip { self.paceTip(width: tipWidth) .offset(x: tipOffset) } } .clipped() if self.isHighlighted { bar .compositingGroup() .drawingGroup() } else if needsPunchCompositing { bar .compositingGroup() } else { bar } } .frame(height: 6) .accessibilityLabel(self.accessibilityLabel) .accessibilityValue("\(Int(self.clamped)) percent") } private func actualBar(width: CGFloat) -> some View { Capsule() .fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint)) .frame(width: width) .contentShape(Rectangle()) .allowsHitTesting(false) } private func paceTip(width: CGFloat) -> some View { let isDeficit = self.paceOnTop == false let useDeficitRed = isDeficit && self.isHighlighted == false return GeometryReader { proxy in let size = proxy.size let rect = CGRect(origin: .zero, size: size) let scale = max(self.displayScale, 1) let stripes = Self.paceStripePaths(size: size, scale: scale) let stripeColor: Color = if self.isHighlighted { .white } else if useDeficitRed { .red } else { .green } ZStack { Canvas { context, _ in context.clip(to: Path(rect)) context.fill(stripes.punched, with: .color(.white.opacity(0.9))) } .blendMode(.destinationOut) Canvas { context, _ in context.clip(to: Path(rect)) context.fill(stripes.center, with: .color(stripeColor)) } } } .frame(width: width) .contentShape(Rectangle()) .allowsHitTesting(false) } private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) { let rect = CGRect(origin: .zero, size: size) let extend = size.height * 2 let stripeTopY: CGFloat = -extend let stripeBottomY: CGFloat = size.height + extend let align: (CGFloat) -> CGFloat = { value in (value * scale).rounded() / scale } let stripeWidth = Self.paceStripeWidth(for: scale) let punchWidth = stripeWidth * 3 let stripeInset = 1 / scale let stripeAnchorX = align(rect.maxX - stripeInset) let stripeMinY = align(stripeTopY) let stripeMaxY = align(stripeBottomY) let anchorTopX = stripeAnchorX var punchedStripe = Path() var centerStripe = Path() let availableWidth = (anchorTopX - punchWidth) - rect.minX guard availableWidth >= 0 else { return (punchedStripe, centerStripe) } let punchRightTopX = align(anchorTopX) let punchLeftTopX = punchRightTopX - punchWidth let punchRightBottomX = punchRightTopX let punchLeftBottomX = punchLeftTopX punchedStripe.addPath(Path { path in path.move(to: CGPoint(x: punchLeftTopX, y: stripeMinY)) path.addLine(to: CGPoint(x: punchRightTopX, y: stripeMinY)) path.addLine(to: CGPoint(x: punchRightBottomX, y: stripeMaxY)) path.addLine(to: CGPoint(x: punchLeftBottomX, y: stripeMaxY)) path.closeSubpath() }) let centerLeftTopX = align(punchLeftTopX + (punchWidth - stripeWidth) / 2) let centerRightTopX = centerLeftTopX + stripeWidth let centerRightBottomX = centerRightTopX let centerLeftBottomX = centerLeftTopX centerStripe.addPath(Path { path in path.move(to: CGPoint(x: centerLeftTopX, y: stripeMinY)) path.addLine(to: CGPoint(x: centerRightTopX, y: stripeMinY)) path.addLine(to: CGPoint(x: centerRightBottomX, y: stripeMaxY)) path.addLine(to: CGPoint(x: centerLeftBottomX, y: stripeMaxY)) path.closeSubpath() }) return (punchedStripe, centerStripe) } private static func clampedPercent(_ value: Double?) -> Double { guard let value else { return 0 } return min(100, max(0, value)) } } ================================================ FILE: Sources/CodexBar/UsageStore+Accessors.swift ================================================ import CodexBarCore import Foundation extension UsageStore { var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } var claudeSnapshot: UsageSnapshot? { self.snapshots[.claude] } var lastCodexError: String? { self.errors[.codex] } var lastClaudeError: String? { self.errors[.claude] } func error(for provider: UsageProvider) -> String? { self.errors[provider] } func status(for provider: UsageProvider) -> ProviderStatus? { guard self.statusChecksEnabled else { return nil } return self.statuses[provider] } func statusIndicator(for provider: UsageProvider) -> ProviderStatusIndicator { self.status(for: provider)?.indicator ?? .none } func accountInfo() -> AccountInfo { self.codexFetcher.loadAccountInfo() } } ================================================ FILE: Sources/CodexBar/UsageStore+ClaudeDebug.swift ================================================ import CodexBarCore import Foundation import SweetCookieKit @MainActor extension UsageStore { func debugClaudeDump() async -> String { await ClaudeStatusProbe.latestDumps() } } extension UsageStore { struct ClaudeDebugLogConfiguration: Sendable { let runtime: CodexBarCore.ProviderRuntime let sourceMode: ProviderSourceMode let environment: [String: String] let webExtrasEnabled: Bool let usageDataSource: ClaudeUsageDataSource let cookieSource: ProviderCookieSource let cookieHeader: String let keepCLISessionsAlive: Bool } static func debugClaudeLog( browserDetection: BrowserDetection, configuration: ClaudeDebugLogConfiguration) async -> String { struct OAuthDebugProbe: Sendable { let hasCredentials: Bool let ownerRawValue: String let sourceRawValue: String let isExpired: Bool } return await runWithTimeout(seconds: 15) { var lines: [String] = [] let manualHeader = configuration.cookieSource == .manual ? CookieHeaderNormalizer.normalize(configuration.cookieHeader) : nil let hasKey = if configuration.cookieSource == .off { false } else if let manualHeader { ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: manualHeader) } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: browserDetection) { msg in lines.append(msg) } } let oauthProbe = await withTaskGroup(of: OAuthDebugProbe.self) { group in // Preserve task-local test overrides while keeping the keychain read off the calling task. group.addTask(priority: .utility) { let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord( environment: configuration.environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true, allowClaudeKeychainRepairWithoutPrompt: false) return OAuthDebugProbe( hasCredentials: oauthRecord?.credentials.scopes.contains("user:profile") == true, ownerRawValue: oauthRecord?.owner.rawValue ?? "none", sourceRawValue: oauthRecord?.source.rawValue ?? "none", isExpired: oauthRecord?.credentials.isExpired ?? false) } return await group.next() ?? OAuthDebugProbe( hasCredentials: false, ownerRawValue: "none", sourceRawValue: "none", isExpired: false) } let hasOAuthCredentials = ClaudeOAuthPlanningAvailability.isAvailable( runtime: configuration.runtime, sourceMode: configuration.sourceMode, environment: configuration.environment) let hasClaudeBinary = ClaudeCLIResolver.isAvailable(environment: configuration.environment) let delegatedCooldownSeconds = ClaudeOAuthDelegatedRefreshCoordinator.cooldownRemainingSeconds() let planningInput = ClaudeSourcePlanningInput( runtime: configuration.runtime, selectedDataSource: configuration.usageDataSource, webExtrasEnabled: configuration.webExtrasEnabled, hasWebSession: hasKey, hasCLI: hasClaudeBinary, hasOAuthCredentials: hasOAuthCredentials) let plan = ClaudeSourcePlanner.resolve(input: planningInput) let strategy = plan.compatibilityStrategy lines.append(contentsOf: plan.debugLines()) lines.append("hasSessionKey=\(hasKey)") lines.append("hasOAuthCredentials=\(hasOAuthCredentials)") lines.append("oauthCredentialOwner=\(oauthProbe.ownerRawValue)") lines.append("oauthCredentialSource=\(oauthProbe.sourceRawValue)") lines.append("oauthCredentialExpired=\(oauthProbe.isExpired)") lines.append("delegatedRefreshCLIAvailable=\(hasClaudeBinary)") lines.append("delegatedRefreshCooldownActive=\(delegatedCooldownSeconds != nil)") if let delegatedCooldownSeconds { lines.append("delegatedRefreshCooldownSeconds=\(delegatedCooldownSeconds)") } lines.append("hasClaudeBinary=\(hasClaudeBinary)") if strategy?.useWebExtras == true { lines.append("web_extras=enabled") } lines.append("") guard let strategy else { lines.append("No planner-selected Claude source.") return lines.joined(separator: "\n") } switch strategy.dataSource { case .auto: lines.append("Auto source selected.") return lines.joined(separator: "\n") case .web: do { let web: ClaudeWebAPIFetcher.WebUsageData = if let manualHeader { try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: manualHeader) { msg in lines.append(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: browserDetection) { msg in lines.append(msg) } } lines.append("") lines.append("Web API summary:") let sessionReset = web.sessionResetsAt?.description ?? "nil" lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)") if let weekly = web.weeklyPercentUsed { let weeklyReset = web.weeklyResetsAt?.description ?? "nil" lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)") } else { lines.append("weekly_used=nil") } lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")") if let extra = web.extraUsageCost { let resetsAt = extra.resetsAt?.description ?? "nil" let period = extra.period ?? "nil" let line = "extra_usage used=\(extra.used) limit=\(extra.limit) " + "currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)" lines.append(line) } else { lines.append("extra_usage=nil") } return lines.joined(separator: "\n") } catch { lines.append("Web API failed: \(error.localizedDescription)") return lines.joined(separator: "\n") } case .cli: let fetcher = ClaudeUsageFetcher( browserDetection: browserDetection, environment: configuration.environment, runtime: configuration.runtime, dataSource: configuration.usageDataSource, keepCLISessionsAlive: configuration.keepCLISessionsAlive) let cli = await fetcher.debugRawProbe(model: "sonnet") lines.append(cli) return lines.joined(separator: "\n") case .oauth: lines.append("OAuth source selected.") return lines.joined(separator: "\n") } } } } ================================================ FILE: Sources/CodexBar/UsageStore+HighestUsage.swift ================================================ import CodexBarCore import Foundation @MainActor extension UsageStore { /// Returns the enabled provider with the highest usage percentage (closest to rate limit). /// Excludes providers that are fully rate-limited. func providerWithHighestUsage() -> (provider: UsageProvider, usedPercent: Double)? { var highest: (provider: UsageProvider, usedPercent: Double)? for provider in self.enabledProviders() { guard let snapshot = self.snapshots[provider] else { continue } let window = self.menuBarMetricWindowForHighestUsage(provider: provider, snapshot: snapshot) let percent = window?.usedPercent ?? 0 guard !self.shouldExcludeFromHighestUsage( provider: provider, snapshot: snapshot, metricPercent: percent) else { continue } if highest == nil || percent > highest!.usedPercent { highest = (provider, percent) } } return highest } private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { switch self.settings.menuBarMetricPreference(for: provider) { case .primary: return snapshot.primary ?? snapshot.secondary case .secondary: return snapshot.secondary ?? snapshot.primary case .average: guard let primary = snapshot.primary, let secondary = snapshot.secondary else { return snapshot.primary ?? snapshot.secondary } let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) case .automatic: if provider == .factory || provider == .kimi { return snapshot.secondary ?? snapshot.primary } if provider == .copilot, let primary = snapshot.primary, let secondary = snapshot.secondary { // Copilot can expose chat + completions quotas; rank by the more constrained one. return primary.usedPercent >= secondary.usedPercent ? primary : secondary } return snapshot.primary ?? snapshot.secondary } } private func shouldExcludeFromHighestUsage( provider: UsageProvider, snapshot: UsageSnapshot, metricPercent: Double) -> Bool { guard metricPercent >= 100 else { return false } if provider == .copilot, self.settings.menuBarMetricPreference(for: provider) == .automatic, let primary = snapshot.primary, let secondary = snapshot.secondary { // In automatic mode Copilot can have one depleted lane while another still has quota. return primary.usedPercent >= 100 && secondary.usedPercent >= 100 } return true } } ================================================ FILE: Sources/CodexBar/UsageStore+HistoricalPace.swift ================================================ import CodexBarCore import CryptoKit import Foundation @MainActor extension UsageStore { private static let minimumPaceExpectedPercent: Double = 3 private static let backfillMaxTimestampMismatch: TimeInterval = 5 * 60 func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { guard provider == .codex || provider == .claude else { return nil } guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? if provider == .codex, self.settings.historicalTrackingEnabled { let codexAccountKey = self.codexHistoricalAccountKey() if self.codexHistoricalDatasetAccountKey == codexAccountKey, let historical = CodexHistoricalPaceEvaluator.evaluate( window: window, now: now, dataset: self.codexHistoricalDataset) { resolved = historical } else { resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) } } else { resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) } guard let resolved else { return nil } guard resolved.expectedUsedPercent >= Self.minimumPaceExpectedPercent else { return nil } return resolved } func recordCodexHistoricalSampleIfNeeded(snapshot: UsageSnapshot) { guard self.settings.historicalTrackingEnabled else { return } guard let weekly = snapshot.secondary else { return } let sampledAt = snapshot.updatedAt let accountKey = self.codexHistoricalAccountKey(preferredEmail: snapshot.accountEmail(for: .codex)) let historyStore = self.historicalUsageHistoryStore Task.detached(priority: .utility) { [weak self] in let dataset = await historyStore.recordCodexWeekly( window: weekly, sampledAt: sampledAt, accountKey: accountKey) await MainActor.run { [weak self] in self?.setCodexHistoricalDataset(dataset, accountKey: accountKey) } } } func refreshHistoricalDatasetIfNeeded() async { if !self.settings.historicalTrackingEnabled { self.setCodexHistoricalDataset(nil, accountKey: nil) return } let accountKey = self.codexHistoricalAccountKey(dashboard: self.openAIDashboard) let dataset = await self.historicalUsageHistoryStore.loadCodexDataset(accountKey: accountKey) self.setCodexHistoricalDataset(dataset, accountKey: accountKey) if let dashboard = self.openAIDashboard { self.backfillCodexHistoricalFromDashboardIfNeeded(dashboard) } } func backfillCodexHistoricalFromDashboardIfNeeded(_ dashboard: OpenAIDashboardSnapshot) { guard self.settings.historicalTrackingEnabled else { return } guard !dashboard.usageBreakdown.isEmpty else { return } let codexSnapshot = self.snapshots[.codex] let accountKey = self.codexHistoricalAccountKey( preferredEmail: codexSnapshot?.accountEmail(for: .codex), dashboard: dashboard) let referenceWindow: RateWindow let calibrationAt: Date if let dashboardWeekly = dashboard.secondaryLimit { referenceWindow = dashboardWeekly calibrationAt = dashboard.updatedAt } else if let codexSnapshot, let snapshotWeekly = codexSnapshot.secondary { let mismatch = abs(codexSnapshot.updatedAt.timeIntervalSince(dashboard.updatedAt)) guard mismatch <= Self.backfillMaxTimestampMismatch else { return } referenceWindow = snapshotWeekly calibrationAt = min(codexSnapshot.updatedAt, dashboard.updatedAt) } else { return } let historyStore = self.historicalUsageHistoryStore let usageBreakdown = dashboard.usageBreakdown Task.detached(priority: .utility) { [weak self] in let dataset = await historyStore.backfillCodexWeeklyFromUsageBreakdown( usageBreakdown, referenceWindow: referenceWindow, now: calibrationAt, accountKey: accountKey) await MainActor.run { [weak self] in self?.setCodexHistoricalDataset(dataset, accountKey: accountKey) } } } private func setCodexHistoricalDataset(_ dataset: CodexHistoricalDataset?, accountKey: String?) { self.codexHistoricalDataset = dataset self.codexHistoricalDatasetAccountKey = accountKey self.historicalPaceRevision += 1 } private func codexHistoricalAccountKey( preferredEmail: String? = nil, dashboard: OpenAIDashboardSnapshot? = nil) -> String? { let sourceEmail = preferredEmail ?? self.snapshots[.codex]?.accountEmail(for: .codex) ?? dashboard?.signedInEmail ?? self.codexAccountEmailForOpenAIDashboard() guard let sourceEmail else { return nil } let normalized = sourceEmail .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !normalized.isEmpty else { return nil } return Self.sha256Hex(normalized) } private static func sha256Hex(_ input: String) -> String { let digest = SHA256.hash(data: Data(input.utf8)) return digest.map { String(format: "%02x", $0) }.joined() } } ================================================ FILE: Sources/CodexBar/UsageStore+Logging.swift ================================================ import CodexBarCore extension UsageStore { func logStartupState() { let modeSnapshot: [String: String] = [ "codexUsageSource": self.settings.codexUsageDataSource.rawValue, "claudeUsageSource": self.settings.claudeUsageDataSource.rawValue, "kiloUsageSource": self.settings.kiloUsageDataSource.rawValue, "codexCookieSource": self.settings.codexCookieSource.rawValue, "claudeCookieSource": self.settings.claudeCookieSource.rawValue, "cursorCookieSource": self.settings.cursorCookieSource.rawValue, "opencodeCookieSource": self.settings.opencodeCookieSource.rawValue, "factoryCookieSource": self.settings.factoryCookieSource.rawValue, "minimaxCookieSource": self.settings.minimaxCookieSource.rawValue, "kimiCookieSource": self.settings.kimiCookieSource.rawValue, "augmentCookieSource": self.settings.augmentCookieSource.rawValue, "ampCookieSource": self.settings.ampCookieSource.rawValue, "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, "openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0", "claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0", "kiloExtras": self.settings.kiloExtrasEnabled ? "1" : "0", ] ProviderLogging.logStartupState( logger: self.providerLogger, providers: Array(self.providerMetadata.keys), isEnabled: { provider in self.settings.isProviderEnabled( provider: provider, metadata: self.providerMetadata[provider]!) }, modeSnapshot: modeSnapshot) } } ================================================ FILE: Sources/CodexBar/UsageStore+OpenAIWeb.swift ================================================ import Foundation // MARK: - OpenAI web error messaging extension UsageStore { func openAIDashboardFriendlyError( body: String, targetEmail: String?, cookieImportStatus: String?) -> String? { let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) let status = cookieImportStatus?.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return [ "OpenAI web dashboard returned an empty page.", "Sign in to chatgpt.com and update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") } let lower = trimmed.lowercased() let looksLikePublicLanding = lower.contains("skip to content") && (lower.contains("about") || lower.contains("openai") || lower.contains("chatgpt")) let looksLoggedOut = lower.contains("sign in") || lower.contains("log in") || lower.contains("create account") || lower.contains("continue with google") || lower.contains("continue with apple") || lower.contains("continue with microsoft") guard looksLikePublicLanding || looksLoggedOut else { return nil } let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let targetLabel = (emailLabel?.isEmpty == false) ? emailLabel! : "your OpenAI account" if let status, !status.isEmpty { if status.contains("cookies do not match Codex account") || status.localizedCaseInsensitiveContains("cookie import failed") { return [ status, "Sign in to chatgpt.com as \(targetLabel), then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") } } return [ "OpenAI web dashboard returned a public page (not signed in).", "Sign in to chatgpt.com as \(targetLabel), then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") } } ================================================ FILE: Sources/CodexBar/UsageStore+Refresh.swift ================================================ import CodexBarCore import Foundation extension UsageStore { /// Force refresh Augment session (called from UI button) func forceRefreshAugmentSession() async { await self.performRuntimeAction(.forceSessionRefresh, for: .augment) } func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { guard let spec = self.providerSpecs[provider] else { return } if !spec.isEnabled(), !allowDisabled { self.refreshingProviders.remove(provider) await MainActor.run { self.snapshots.removeValue(forKey: provider) self.errors[provider] = nil self.lastSourceLabels.removeValue(forKey: provider) self.lastFetchAttempts.removeValue(forKey: provider) self.accountSnapshots.removeValue(forKey: provider) self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.failureGates[provider]?.reset() self.tokenFailureGates[provider]?.reset() self.statuses.removeValue(forKey: provider) self.lastKnownSessionRemaining.removeValue(forKey: provider) self.lastKnownSessionWindowSource.removeValue(forKey: provider) self.lastTokenFetchAt.removeValue(forKey: provider) } return } self.refreshingProviders.insert(provider) defer { self.refreshingProviders.remove(provider) } let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) return } else { _ = await MainActor.run { self.accountSnapshots.removeValue(forKey: provider) } } let fetchContext = spec.makeFetchContext() let descriptor = spec.descriptor // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. let outcome = await withTaskGroup( of: ProviderFetchOutcome.self, returning: ProviderFetchOutcome.self) { group in group.addTask { await descriptor.fetchOutcome(context: fetchContext) } return await group.next()! } if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() { await MainActor.run { self.snapshots.removeValue(forKey: .claude) self.errors[.claude] = nil self.lastSourceLabels.removeValue(forKey: .claude) self.lastFetchAttempts.removeValue(forKey: .claude) self.accountSnapshots.removeValue(forKey: .claude) self.tokenSnapshots.removeValue(forKey: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() self.tokenFailureGates[.claude]?.reset() self.lastTokenFetchAt.removeValue(forKey: .claude) } } await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) await MainActor.run { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) self.snapshots[provider] = scoped self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) runtime.providerDidRefresh(context: context, provider: provider) } if provider == .codex { self.recordCodexHistoricalSampleIfNeeded(snapshot: scoped) } case let .failure(error): await MainActor.run { let hadPriorData = self.snapshots[provider] != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.errors[provider] = error.localizedDescription self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil } } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) runtime.providerDidFail(context: context, provider: provider, error: error) } } } } ================================================ FILE: Sources/CodexBar/UsageStore+Status.swift ================================================ import Foundation extension UsageStore { static func fetchStatus(from baseURL: URL) async throws -> ProviderStatus { let apiURL = baseURL.appendingPathComponent("api/v2/status.json") var request = URLRequest(url: apiURL) request.timeoutInterval = 10 let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) struct Response: Decodable { struct Status: Decodable { let indicator: String let description: String? } struct Page: Decodable { let updatedAt: Date? private enum CodingKeys: String, CodingKey { case updatedAt = "updated_at" } } let page: Page? let status: Status } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: raw) { return date } formatter.formatOptions = [.withInternetDateTime] if let date = formatter.date(from: raw) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") } let response = try decoder.decode(Response.self, from: data) let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown return ProviderStatus( indicator: indicator, description: response.status.description, updatedAt: response.page?.updatedAt) } static func fetchWorkspaceStatus(productID: String) async throws -> ProviderStatus { guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else { throw URLError(.badURL) } var request = URLRequest(url: url) request.timeoutInterval = 10 let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: raw) { return date } formatter.formatOptions = [.withInternetDateTime] if let date = formatter.date(from: raw) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") } let incidents = try decoder.decode([GoogleWorkspaceIncident].self, from: data) let active = incidents.filter { $0.isRelevant(productID: productID) && $0.isActive } guard !active.isEmpty else { return ProviderStatus(indicator: .none, description: nil, updatedAt: nil) } var best: ( indicator: ProviderStatusIndicator, incident: GoogleWorkspaceIncident, update: GoogleWorkspaceUpdate?) best = (indicator: .none, incident: active[0], update: active[0].mostRecentUpdate ?? active[0].updates?.last) for incident in active { let update = incident.mostRecentUpdate ?? incident.updates?.last let indicator = Self.workspaceIndicator( status: update?.status ?? incident.statusImpact, severity: incident.severity) if Self.indicatorRank(indicator) <= Self.indicatorRank(best.indicator) { continue } best = (indicator: indicator, incident: incident, update: update) } let description = Self.workspaceSummary(from: best.update?.text ?? best.incident.externalDesc) let updatedAt = best.update?.when ?? best.incident.modified ?? best.incident.begin return ProviderStatus(indicator: best.indicator, description: description, updatedAt: updatedAt) } private static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 case .maintenance: 1 case .minor: 2 case .major: 3 case .critical: 4 case .unknown: 1 } } private static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { switch status?.uppercased() { case "AVAILABLE": return .none case "SERVICE_INFORMATION": return .minor case "SERVICE_DISRUPTION": return .major case "SERVICE_OUTAGE": return .critical case "SERVICE_MAINTENANCE", "SCHEDULED_MAINTENANCE": return .maintenance default: break } switch severity?.lowercased() { case "low": return .minor case "medium": return .major case "high": return .critical default: return .minor } } private static func workspaceSummary(from text: String?) -> String? { guard let text else { return nil } let normalized = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") let lines = normalized.split(separator: "\n", omittingEmptySubsequences: true) for rawLine in lines { let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { continue } let lower = trimmed.lowercased() if lower.hasPrefix("**summary") || lower.hasPrefix("**description") || lower == "summary" { continue } var cleaned = trimmed.replacingOccurrences(of: "**", with: "") cleaned = cleaned.replacingOccurrences( of: #"\[([^\]]+)\]\([^)]+\)"#, with: "$1", options: .regularExpression) if cleaned.hasPrefix("- ") { cleaned.removeFirst(2) } cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) if !cleaned.isEmpty { return cleaned } } return nil } private struct GoogleWorkspaceIncident: Decodable { let begin: Date? let end: Date? let modified: Date? let externalDesc: String? let statusImpact: String? let severity: String? let affectedProducts: [GoogleWorkspaceProduct]? let currentlyAffectedProducts: [GoogleWorkspaceProduct]? let mostRecentUpdate: GoogleWorkspaceUpdate? let updates: [GoogleWorkspaceUpdate]? var isActive: Bool { self.end == nil } func isRelevant(productID: String) -> Bool { if let current = currentlyAffectedProducts { return current.contains(where: { $0.id == productID }) } return self.affectedProducts?.contains(where: { $0.id == productID }) ?? false } } private struct GoogleWorkspaceProduct: Decodable { let title: String? let id: String } private struct GoogleWorkspaceUpdate: Decodable { let when: Date? let status: String? let text: String? } } ================================================ FILE: Sources/CodexBar/UsageStore+Timeout.swift ================================================ import Foundation extension UsageStore { nonisolated static func runWithTimeout( seconds: Double, operation: @escaping @Sendable () async -> String) async -> String { await withTaskGroup(of: String?.self) { group -> String in group.addTask { await operation() } group.addTask { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) return nil } let result = await group.next()?.flatMap(\.self) group.cancelAll() return result ?? "Probe timed out after \(Int(seconds))s" } } } ================================================ FILE: Sources/CodexBar/UsageStore+TokenAccounts.swift ================================================ import CodexBarCore import Foundation struct TokenAccountUsageSnapshot: Identifiable { let id: UUID let account: ProviderTokenAccount let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? init(account: ProviderTokenAccount, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) { self.id = account.id self.account = account self.snapshot = snapshot self.error = error self.sourceLabel = sourceLabel } } extension UsageStore { func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) } func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first var snapshots: [TokenAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? for account in limitedAccounts { let override = TokenAccountOverride(provider: provider, account: account) let outcome = await self.fetchOutcome(provider: provider, override: override) let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) snapshots.append(resolved.snapshot) if account.id == effectiveSelected?.id { selectedOutcome = outcome selectedSnapshot = resolved.usage } } await MainActor.run { self.accountSnapshots[provider] = snapshots } if let selectedOutcome { await self.applySelectedOutcome( selectedOutcome, provider: provider, account: effectiveSelected, fallbackSnapshot: selectedSnapshot) } } func limitedTokenAccounts( _ accounts: [ProviderTokenAccount], selected: ProviderTokenAccount?) -> [ProviderTokenAccount] { let limit = 6 if accounts.count <= limit { return accounts } var limited = Array(accounts.prefix(limit)) if let selected, !limited.contains(where: { $0.id == selected.id }) { limited.removeLast() limited.append(selected) } return limited } func fetchOutcome( provider: UsageProvider, override: TokenAccountOverride?) async -> ProviderFetchOutcome { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let sourceMode = self.sourceMode(for: provider) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override) let env = ProviderRegistry.makeEnvironment( base: ProcessInfo.processInfo.environment, provider: provider, settings: self.settings, tokenOverride: override) let verbose = self.settings.isVerboseLoggingEnabled let context = ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, env: env, settings: snapshot, fetcher: self.codexFetcher, claudeFetcher: self.claudeFetcher, browserDetection: self.browserDetection) return await descriptor.fetchOutcome(context: context) } func sourceMode(for provider: UsageProvider) -> ProviderSourceMode { ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: self.settings)) ?? .auto } private struct ResolvedAccountOutcome { let snapshot: TokenAccountUsageSnapshot let usage: UsageSnapshot? } private func resolveAccountOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, account: ProviderTokenAccount) -> ResolvedAccountOutcome { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) let labeled = self.applyAccountLabel(scoped, provider: provider, account: account) let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: labeled, error: nil, sourceLabel: result.sourceLabel) return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) case let .failure(error): let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, error: error.localizedDescription, sourceLabel: nil) return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) } } func applySelectedOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, account: ProviderTokenAccount?, fallbackSnapshot: UsageSnapshot?) async { await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) let labeled: UsageSnapshot = if let account { self.applyAccountLabel(scoped, provider: provider, account: account) } else { scoped } await MainActor.run { self.handleSessionQuotaTransition(provider: provider, snapshot: labeled) self.snapshots[provider] = labeled self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } case let .failure(error): await MainActor.run { let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.errors[provider] = error.localizedDescription self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil } } } } func applyAccountLabel( _ snapshot: UsageSnapshot, provider: UsageProvider, account: ProviderTokenAccount) -> UsageSnapshot { let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty else { return snapshot } let existing = snapshot.identity(for: provider) let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedEmail = (email?.isEmpty ?? true) ? label : email let identity = ProviderIdentitySnapshot( providerID: provider, accountEmail: resolvedEmail, accountOrganization: existing?.accountOrganization, loginMethod: existing?.loginMethod) return snapshot.withIdentity(identity) } } ================================================ FILE: Sources/CodexBar/UsageStore+TokenCost.swift ================================================ import CodexBarCore import Foundation extension UsageStore { func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } func tokenError(for provider: UsageProvider) -> String? { self.tokenErrors[provider] } func tokenLastAttemptAt(for provider: UsageProvider) -> Date? { self.lastTokenFetchAt[provider] } func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { self.tokenRefreshInFlight.contains(provider) } nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! return root .appendingPathComponent("CodexBar", isDirectory: true) .appendingPathComponent("cost-usage", isDirectory: true) } nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } } ================================================ FILE: Sources/CodexBar/UsageStore+WidgetSnapshot.swift ================================================ import CodexBarCore import Foundation #if canImport(WidgetKit) import WidgetKit #endif extension UsageStore { func persistWidgetSnapshot(reason: String) { let snapshot = self.makeWidgetSnapshot() Task.detached(priority: .utility) { WidgetSnapshotStore.save(snapshot) #if canImport(WidgetKit) await MainActor.run { WidgetCenter.shared.reloadAllTimelines() } #endif } } private func makeWidgetSnapshot() -> WidgetSnapshot { let enabledProviders = self.enabledProviders() let entries = UsageProvider.allCases.compactMap { provider in self.makeWidgetEntry(for: provider) } return WidgetSnapshot(entries: entries, enabledProviders: enabledProviders, generatedAt: Date()) } private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { guard let snapshot = self.snapshots[provider] else { return nil } let tokenSnapshot = self.tokenSnapshots[provider] let dailyUsage = tokenSnapshot?.daily.map { entry in WidgetSnapshot.DailyUsagePoint( dayKey: entry.date, totalTokens: entry.totalTokens, costUSD: entry.costUSD) } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot) let creditsRemaining = provider == .codex ? self.credits?.remaining : nil let codeReviewRemaining = provider == .codex ? self.openAIDashboard?.codeReviewRemainingPercent : nil return WidgetSnapshot.ProviderEntry( provider: provider, updatedAt: snapshot.updatedAt, primary: snapshot.primary, secondary: snapshot.secondary, tertiary: snapshot.tertiary, creditsRemaining: creditsRemaining, codeReviewRemainingPercent: codeReviewRemaining, tokenUsage: tokenUsage, dailyUsage: dailyUsage) } private nonisolated static func widgetTokenUsageSummary( from snapshot: CostUsageTokenSnapshot?) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) return WidgetSnapshot.TokenUsageSummary( sessionCostUSD: snapshot.sessionCostUSD, sessionTokens: snapshot.sessionTokens, last30DaysCostUSD: snapshot.last30DaysCostUSD, last30DaysTokens: monthTokensValue) } } ================================================ FILE: Sources/CodexBar/UsageStore.swift ================================================ import AppKit import CodexBarCore import Foundation import Observation import SweetCookieKit // MARK: - Observation helpers @MainActor extension UsageStore { var menuObservationToken: Int { _ = self.snapshots _ = self.errors _ = self.lastSourceLabels _ = self.lastFetchAttempts _ = self.accountSnapshots _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight _ = self.credits _ = self.lastCreditsError _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin _ = self.openAIDashboardCookieImportStatus _ = self.openAIDashboardCookieImportDebugLog _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders _ = self.pathDebugInfo _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision return 0 } func observeSettingsChanges() { withObservationTracking { _ = self.settings.refreshFrequency _ = self.settings.statusChecksEnabled _ = self.settings.sessionQuotaNotificationsEnabled _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.randomBlinkEnabled _ = self.settings.configRevision for implementation in ProviderCatalog.all { implementation.observeSettings(self.settings) } _ = self.settings.showAllTokenAccountsInMenu _ = self.settings.tokenAccountsByProvider _ = self.settings.mergeIcons _ = self.settings.selectedMenuProvider _ = self.settings.debugLoadingPattern _ = self.settings.debugKeepCLISessionsAlive _ = self.settings.historicalTrackingEnabled } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() self.updateProviderRuntimes() await self.refreshHistoricalDatasetIfNeeded() await self.refresh() } } } } @MainActor @Observable final class UsageStore { enum StartupBehavior { case automatic case full case testing var automaticallyStartsBackgroundWork: Bool { switch self { case .automatic, .full: true case .testing: false } } func resolved(isRunningTests: Bool) -> StartupBehavior { switch self { case .automatic: isRunningTests ? .testing : .full case .full, .testing: self } } } var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] var credits: CreditsSnapshot? var lastCreditsError: String? var openAIDashboard: OpenAIDashboardSnapshot? var lastOpenAIDashboardError: String? var openAIDashboardRequiresLogin: Bool = false var openAIDashboardCookieImportStatus: String? var openAIDashboardCookieImportDebugLog: String? var versions: [UsageProvider: String] = [:] var isRefreshing = false var refreshingProviders: Set = [] var debugForceAnimation = false var pathDebugInfo: PathDebugSnapshot = .empty var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 @ObservationIgnored private var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored private var creditsFailureStreak: Int = 0 @ObservationIgnored private var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored private var lastOpenAIDashboardTargetEmail: String? @ObservationIgnored private var lastOpenAIDashboardCookieImportAttemptAt: Date? @ObservationIgnored private var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored private var openAIWebAccountDidChange: Bool = false @ObservationIgnored let codexFetcher: UsageFetcher @ObservationIgnored let claudeFetcher: any ClaudeUsageFetching @ObservationIgnored private let costUsageFetcher: CostUsageFetcher @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore @ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying @ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) @ObservationIgnored private let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb) @ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost) @ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.augment) @ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers) @ObservationIgnored private var openAIWebDebugLines: [String] = [] @ObservationIgnored var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var pathDebugRefreshTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored private let startupBehavior: StartupBehavior init( fetcher: UsageFetcher, browserDetection: BrowserDetection, claudeFetcher: (any ClaudeUsageFetching)? = nil, costUsageFetcher: CostUsageFetcher = CostUsageFetcher(), settings: SettingsStore, registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic) { self.codexFetcher = fetcher self.browserDetection = browserDetection self.claudeFetcher = claudeFetcher ?? ClaudeUsageFetcher(browserDetection: browserDetection) self.costUsageFetcher = costUsageFetcher self.settings = settings self.registry = registry self.historicalUsageHistoryStore = historicalUsageHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) self.providerMetadata = registry.metadata self .failureGates = Dictionary( uniqueKeysWithValues: UsageProvider.allCases .map { ($0, ConsecutiveFailureGate()) }) self.tokenFailureGates = Dictionary( uniqueKeysWithValues: UsageProvider.allCases .map { ($0, ConsecutiveFailureGate()) }) self.providerSpecs = registry.specs( settings: settings, metadata: self.providerMetadata, codexFetcher: fetcher, claudeFetcher: self.claudeFetcher, browserDetection: browserDetection) self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( codexBinary: nil, claudeBinary: nil, geminiBinary: nil, effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in self?.schedulePathDebugInfoRefresh() } LoginShellPathCache.shared.captureOnce { [weak self] _ in Task { @MainActor [weak self] in self?.schedulePathDebugInfoRefresh() } } Task { @MainActor [weak self] in await self?.refreshHistoricalDatasetIfNeeded() } Task { await self.refresh() } self.startTimer() self.startTokenTimer() } private static func isRunningTestsProcess() -> Bool { let environment = ProcessInfo.processInfo.environment if environment["XCTestConfigurationFilePath"] != nil { return true } if environment["XCTestSessionIdentifier"] != nil { return true } if environment["SWIFT_TESTING_ENABLED"] != nil { return true } return CommandLine.arguments.contains { argument in argument.contains("xctest") || argument.contains("swift-testing") } } /// Returns the login method (plan type) for the specified provider, if available. private func loginMethod(for provider: UsageProvider) -> String? { self.snapshots[provider]?.loginMethod(for: provider) } /// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team). /// Returns false for API users or when plan cannot be determined. func isClaudeSubscription() -> Bool { Self.isSubscriptionPlan(self.loginMethod(for: .claude)) } /// Determines if a login method string indicates a Claude subscription plan. /// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive). nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool { ClaudePlan.isSubscriptionLoginMethod(loginMethod) } func version(for provider: UsageProvider) -> String? { self.versions[provider] } var preferredSnapshot: UsageSnapshot? { for provider in self.enabledProviders() { if let snap = self.snapshots[provider] { return snap } } return nil } var iconStyle: IconStyle { let enabled = self.enabledProviders() if enabled.count > 1 { return .combined } if let provider = enabled.first { return self.style(for: provider) } return .codex } var isStale: Bool { for provider in self.enabledProviders() where self.errors[provider] != nil { return true } return false } func enabledProviders() -> [UsageProvider] { // Use cached enablement to avoid repeated UserDefaults lookups in animation ticks. let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) return enabled.filter { self.isProviderAvailable($0) } } /// Enabled providers without availability filtering. Used for display (switcher, merge-icons). func enabledProvidersForDisplay() -> [UsageProvider] { self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) } var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } func metadata(for provider: UsageProvider) -> ProviderMetadata { self.providerMetadata[provider]! } private var codexBrowserCookieOrder: BrowserCookieImportOrder { self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder } func snapshot(for provider: UsageProvider) -> UsageSnapshot? { self.snapshots[provider] } func sourceLabel(for provider: UsageProvider) -> String { var label = self.lastSourceLabels[provider] ?? "" if label.isEmpty { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let modes = descriptor.fetchPlan.sourceModes if modes.count == 1, let mode = modes.first { label = mode.rawValue } else { let context = ProviderSourceLabelContext( provider: provider, settings: self.settings, store: self, descriptor: descriptor) label = ProviderCatalog.implementation(for: provider)? .defaultSourceLabel(context: context) ?? "auto" } } let context = ProviderSourceLabelContext( provider: provider, settings: self.settings, store: self, descriptor: ProviderDescriptorRegistry.descriptor(for: provider)) return ProviderCatalog.implementation(for: provider)? .decorateSourceLabel(context: context, baseLabel: label) ?? label } func fetchAttempts(for provider: UsageProvider) -> [ProviderFetchAttempt] { self.lastFetchAttempts[provider] ?? [] } func style(for provider: UsageProvider) -> IconStyle { self.providerSpecs[provider]?.style ?? .codex } func isStale(provider: UsageProvider) -> Bool { self.errors[provider] != nil } func isEnabled(_ provider: UsageProvider) -> Bool { let enabled = self.settings.isProviderEnabledCached( provider: provider, metadataByProvider: self.providerMetadata) guard enabled else { return false } return self.isProviderAvailable(provider) } func isProviderAvailable(_ provider: UsageProvider) -> Bool { // Availability should mirror the effective fetch environment, including token-account overrides. // Otherwise providers (notably token-account-backed API providers) can fetch successfully but be // hidden from the menu because their credentials are not in ProcessInfo's environment. let environment = ProviderRegistry.makeEnvironment( base: ProcessInfo.processInfo.environment, provider: provider, settings: self.settings, tokenOverride: nil) let context = ProviderAvailabilityContext( provider: provider, settings: self.settings, environment: environment) return ProviderCatalog.implementation(for: provider)? .isAvailable(context: context) ?? true } func performRuntimeAction(_ action: ProviderRuntimeAction, for provider: UsageProvider) async { guard let runtime = self.providerRuntimes[provider] else { return } let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) await runtime.perform(action: action, context: context) } private func updateProviderRuntimes() { for (provider, runtime) in self.providerRuntimes { let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) if self.isEnabled(provider) { runtime.start(context: context) } else { runtime.stop(context: context) } runtime.settingsDidChange(context: context) } } func refresh(forceTokenUsage: Bool = false) async { guard !self.isRefreshing else { return } let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup await ProviderRefreshContext.$current.withValue(refreshPhase) { self.isRefreshing = true defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true } await withTaskGroup(of: Void.self) { group in for provider in UsageProvider.allCases { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } group.addTask { await self.refreshCreditsIfNeeded() } } // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. self.scheduleTokenRefresh(force: forceTokenUsage) // OpenAI web scrape depends on the current Codex account email (which can change after login/account // switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) if self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsIfNeeded() } self.persistWidgetSnapshot(reason: "refresh") } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. func replayLoadingAnimation(duration: TimeInterval = 3) { let current = self.preferredSnapshot self.snapshots.removeAll() self.debugForceAnimation = true Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) if let current, let provider = self.enabledProviders().first { self.snapshots[provider] = current } self.debugForceAnimation = false } } // MARK: - Private private func bindSettings() { self.observeSettingsChanges() } private func startTimer() { self.timerTask?.cancel() guard let wait = self.settings.refreshFrequency.seconds else { return } // Background poller so the menu stays responsive; canceled when settings change or store deallocates. self.timerTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(wait)) await self?.refresh() } } } private func startTokenTimer() { self.tokenTimerTask?.cancel() let wait = self.tokenFetchTTL self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(wait)) await self?.scheduleTokenRefresh(force: false) } } } private func scheduleTokenRefresh(force: Bool) { if force { self.tokenRefreshSequenceTask?.cancel() self.tokenRefreshSequenceTask = nil } else if self.tokenRefreshSequenceTask != nil { return } self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { Task { @MainActor [weak self] in self?.tokenRefreshSequenceTask = nil } } for provider in UsageProvider.allCases { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } } } deinit { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() } enum SessionQuotaWindowSource: String { case primary case copilotSecondaryFallback } private func sessionQuotaWindow( provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { if let primary = snapshot.primary { return (primary, .primary) } if provider == .copilot, let secondary = snapshot.secondary { return (secondary, .copilotSecondaryFallback) } return nil } func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. guard let sessionWindow = self.sessionQuotaWindow(provider: provider, snapshot: snapshot) else { self.lastKnownSessionRemaining.removeValue(forKey: provider) self.lastKnownSessionWindowSource.removeValue(forKey: provider) return } let currentRemaining = sessionWindow.window.remainingPercent let currentSource = sessionWindow.source let previousRemaining = self.lastKnownSessionRemaining[provider] let previousSource = self.lastKnownSessionWindowSource[provider] if let previousSource, previousSource != currentSource { let providerText = provider.rawValue self.sessionQuotaLogger.debug( "session window source changed: provider=\(providerText) prevSource=\(previousSource.rawValue) " + "currSource=\(currentSource.rawValue) curr=\(currentRemaining)") self.lastKnownSessionRemaining[provider] = currentRemaining self.lastKnownSessionWindowSource[provider] = currentSource return } defer { self.lastKnownSessionRemaining[provider] = currentRemaining self.lastKnownSessionWindowSource[provider] = currentSource } guard self.settings.sessionQuotaNotificationsEnabled else { if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || SessionQuotaNotificationLogic.isDepleted(previousRemaining) { let providerText = provider.rawValue let message = "notifications disabled: provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" self.sessionQuotaLogger.debug(message) } return } guard previousRemaining != nil else { if SessionQuotaNotificationLogic.isDepleted(currentRemaining) { let providerText = provider.rawValue let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) self.sessionQuotaNotifier.post(transition: .depleted, provider: provider, badge: nil) } return } let transition = SessionQuotaNotificationLogic.transition( previousRemaining: previousRemaining, currentRemaining: currentRemaining) guard transition != .none else { if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || SessionQuotaNotificationLogic.isDepleted(previousRemaining) { let providerText = provider.rawValue let message = "no transition: provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" self.sessionQuotaLogger.debug(message) } return } let providerText = provider.rawValue let transitionText = String(describing: transition) let message = "transition \(transitionText): provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } private func refreshStatus(_ provider: UsageProvider) async { guard self.settings.statusChecksEnabled else { return } guard let meta = self.providerMetadata[provider] else { return } do { let status: ProviderStatus if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { status = try await Self.fetchStatus(from: baseURL) } else if let productID = meta.statusWorkspaceProductID { status = try await Self.fetchWorkspaceStatus(productID: productID) } else { return } await MainActor.run { self.statuses[provider] = status } } catch { // Keep the previous status to avoid flapping when the API hiccups. await MainActor.run { if self.statuses[provider] == nil { self.statuses[provider] = ProviderStatus( indicator: .unknown, description: error.localizedDescription, updatedAt: nil) } } } } private func refreshCreditsIfNeeded() async { guard self.isEnabled(.codex) else { return } do { let credits = try await self.codexFetcher.loadLatestCredits( keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) await MainActor.run { self.credits = credits self.lastCreditsError = nil self.lastCreditsSnapshot = credits self.creditsFailureStreak = 0 } } catch { let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { await MainActor.run { if let cached = self.lastCreditsSnapshot { self.credits = cached self.lastCreditsError = nil } else { self.credits = nil self.lastCreditsError = "Codex credits are still loading; will retry shortly." } } return } await MainActor.run { self.creditsFailureStreak += 1 if let cached = self.lastCreditsSnapshot { self.credits = cached let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened) self.lastCreditsError = "Last Codex credits refresh failed: \(message). Cached values from \(stamp)." } else { self.lastCreditsError = message self.credits = nil } } } } } extension UsageStore { private static let openAIWebRefreshMultiplier: TimeInterval = 5 private static let openAIWebPrimaryFetchTimeout: TimeInterval = 15 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 private func openAIWebRefreshIntervalSeconds() -> TimeInterval { let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) return base * Self.openAIWebRefreshMultiplier } func requestOpenAIDashboardRefreshIfStale(reason: String) { guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") Task { await self.refreshOpenAIDashboardIfNeeded(force: true) } } private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async { await MainActor.run { self.openAIDashboard = dash self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardSnapshot = dash self.openAIDashboardRequiresLogin = false // Only fill gaps; OAuth/CLI remain the primary sources for usage + credits. if self.snapshots[.codex] == nil, let usage = dash.toUsageSnapshot(provider: .codex, accountEmail: targetEmail) { self.snapshots[.codex] = usage self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() self.lastSourceLabels[.codex] = "openai-web" } if self.credits == nil, let credits = dash.toCreditsSnapshot() { self.credits = credits self.lastCreditsSnapshot = credits self.lastCreditsError = nil self.creditsFailureStreak = 0 } } if let email = targetEmail, !email.isEmpty { OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: email, snapshot: dash)) } self.backfillCodexHistoricalFromDashboardIfNeeded(dash) } private func applyOpenAIDashboardFailure(message: String) async { await MainActor.run { if let cached = self.lastOpenAIDashboardSnapshot { self.openAIDashboard = cached let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened) self.lastOpenAIDashboardError = "Last OpenAI dashboard refresh failed: \(message). Cached values from \(stamp)." } else { self.lastOpenAIDashboardError = message self.openAIDashboard = nil } } } private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async { guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { self.resetOpenAIWebState() return } let targetEmail = self.codexAccountEmailForOpenAIDashboard() self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() if !force, !self.openAIWebAccountDidChange, self.lastOpenAIDashboardError == nil, let snapshot = self.lastOpenAIDashboardSnapshot, now.timeIntervalSince(snapshot.updatedAt) < minInterval { return } if self.openAIWebDebugLines.isEmpty { self.resetOpenAIWebDebugLog(context: "refresh") } else { let stamp = Date().formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh start") } let log: (String) -> Void = { [weak self] line in guard let self else { return } self.logOpenAIWeb(line) } do { let normalized = targetEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() var effectiveEmail = targetEmail // Use a per-email persistent `WKWebsiteDataStore` so multiple dashboard sessions can coexist. // Strategy: // - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts). // - On login-required or account mismatch, import cookies from the configured browser order and retry once. if self.openAIWebAccountDidChange, let targetEmail, !targetEmail.isEmpty { // On account switches, proactively re-import cookies so we don't show stale data from the previous // user. if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, force: true) { effectiveEmail = imported } self.openAIWebAccountDidChange = false } var dash = try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: effectiveEmail, logger: log, debugDumpHTML: false, timeout: Self.openAIWebPrimaryFetchTimeout) if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, force: true) { effectiveEmail = imported } dash = try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: effectiveEmail, logger: log, debugDumpHTML: false, timeout: Self.openAIWebRetryFetchTimeout) } if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { let signedIn = dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" await MainActor.run { self.openAIDashboard = nil self.lastOpenAIDashboardError = [ "OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").", "Switch accounts in your browser and update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") self.openAIDashboardRequiresLogin = true } return } await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) { // Often indicates a missing/stale session without an obvious login prompt. Retry once after // importing cookies from the user's browser. let targetEmail = self.codexAccountEmailForOpenAIDashboard() var effectiveEmail = targetEmail if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { effectiveEmail = imported } do { let dash = try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: effectiveEmail, logger: log, debugDumpHTML: true, timeout: Self.openAIWebRetryFetchTimeout) await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) { let finalBody = retryBody.isEmpty ? body : retryBody let message = self.openAIDashboardFriendlyError( body: finalBody, targetEmail: targetEmail, cookieImportStatus: self.openAIDashboardCookieImportStatus) ?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription await self.applyOpenAIDashboardFailure(message: message) } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } catch OpenAIDashboardFetcher.FetchError.loginRequired { let targetEmail = self.codexAccountEmailForOpenAIDashboard() var effectiveEmail = targetEmail if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { effectiveEmail = imported } do { let dash = try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: effectiveEmail, logger: log, debugDumpHTML: true, timeout: Self.openAIWebRetryFetchTimeout) await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail) } catch OpenAIDashboardFetcher.FetchError.loginRequired { await MainActor.run { self.lastOpenAIDashboardError = [ "OpenAI web access requires a signed-in chatgpt.com session.", "Sign in using \(self.codexBrowserCookieOrder.loginHint), " + "then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") self.openAIDashboard = self.lastOpenAIDashboardSnapshot self.openAIDashboardRequiresLogin = true } } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } // MARK: - OpenAI web account switching /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user. /// This does not delete other per-email WebKit cookie stores (we keep multiple accounts around). func handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: String?) { let normalized = targetEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard let normalized, !normalized.isEmpty else { return } let previous = self.lastOpenAIDashboardTargetEmail self.lastOpenAIDashboardTargetEmail = normalized if let previous, !previous.isEmpty, previous != normalized { let stamp = Date().formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb( "[\(stamp)] Codex account changed: \(previous) → \(normalized); " + "clearing OpenAI web snapshot") self.openAIWebAccountDidChange = true self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" self.lastOpenAIDashboardCookieImportAttemptAt = nil self.lastOpenAIDashboardCookieImportEmail = nil } } func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") let targetEmail = self.codexAccountEmailForOpenAIDashboard() _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) await self.refreshOpenAIDashboardIfNeeded(force: true) } private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true let cookieSource = self.settings.codexCookieSource let now = Date() let lastEmail = self.lastOpenAIDashboardCookieImportEmail let lastAttempt = self.lastOpenAIDashboardCookieImportAttemptAt ?? .distantPast let shouldAttempt: Bool = if force { true } else { if allowAnyAccount { now.timeIntervalSince(lastAttempt) > 300 } else { self.openAIDashboardRequiresLogin && ( lastEmail?.lowercased() != normalizedTarget?.lowercased() || now .timeIntervalSince(lastAttempt) > 300) } } guard shouldAttempt else { return normalizedTarget } self.lastOpenAIDashboardCookieImportEmail = normalizedTarget self.lastOpenAIDashboardCookieImportAttemptAt = now let stamp = now.formatted(date: .abbreviated, time: .shortened) let targetLabel = normalizedTarget ?? "unknown" self.logOpenAIWeb("[\(stamp)] import start (target=\(targetLabel))") do { let log: (String) -> Void = { [weak self] message in guard let self else { return } self.logOpenAIWeb(message) } let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) let result: OpenAIDashboardBrowserCookieImporter.ImportResult switch cookieSource { case .manual: self.settings.ensureCodexCookieLoaded() let manualHeader = self.settings.codexCookieHeader guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid } result = try await importer.importManualCookies( cookieHeader: manualHeader, intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, logger: log) case .auto: result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, logger: log) case .off: result = OpenAIDashboardBrowserCookieImporter.ImportResult( sourceLabel: "Off", cookieCount: 0, signedInEmail: normalizedTarget, matchesCodexEmail: true) } let effectiveEmail = result.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false ? result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) : normalizedTarget self.lastOpenAIDashboardCookieImportEmail = effectiveEmail ?? normalizedTarget await MainActor.run { let signed = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let matchText = result.matchesCodexEmail ? "matches Codex" : "does not match Codex" let sourceLabel = switch cookieSource { case .manual: "Manual cookie header" case .auto: "\(result.sourceLabel) cookies" case .off: "OpenAI cookies disabled" } if let signed, !signed.isEmpty { self.openAIDashboardCookieImportStatus = allowAnyAccount ? [ "Using \(sourceLabel) (\(result.cookieCount)).", "Signed in as \(signed).", ].joined(separator: " ") : [ "Using \(sourceLabel) (\(result.cookieCount)).", "Signed in as \(signed) (\(matchText)).", ].joined(separator: " ") } else { self.openAIDashboardCookieImportStatus = "Using \(sourceLabel) (\(result.cookieCount))." } } return effectiveEmail } catch let err as OpenAIDashboardBrowserCookieImporter.ImportError { switch err { case let .noMatchingAccount(found): let foundText: String = if found.isEmpty { "no signed-in session detected in \(self.codexBrowserCookieOrder.loginHint)" } else { found .sorted { lhs, rhs in if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email } return lhs.sourceLabel < rhs.sourceLabel } .map { "\($0.sourceLabel): \($0.email)" } .joined(separator: " • ") } self.logOpenAIWeb("[\(stamp)] import mismatch: \(foundText)") await MainActor.run { self.openAIDashboardCookieImportStatus = allowAnyAccount ? [ "No signed-in OpenAI web session found.", "Found \(foundText).", ].joined(separator: " ") : [ "Browser cookies do not match Codex account (\(normalizedTarget ?? "unknown")).", "Found \(foundText).", ].joined(separator: " ") // Treat mismatch like "not logged in" for the current Codex account. self.openAIDashboardRequiresLogin = true self.openAIDashboard = nil } case .noCookiesFound, .browserAccessDenied, .dashboardStillRequiresLogin, .manualCookieHeaderInvalid: self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") await MainActor.run { self.openAIDashboardCookieImportStatus = "OpenAI cookie import failed: \(err.localizedDescription)" self.openAIDashboardRequiresLogin = true } } } catch { self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)") await MainActor.run { self.openAIDashboardCookieImportStatus = "Browser cookie import failed: \(error.localizedDescription)" } } return nil } private func resetOpenAIWebDebugLog(context: String) { let stamp = Date().formatted(date: .abbreviated, time: .shortened) self.openAIWebDebugLines.removeAll(keepingCapacity: true) self.openAIDashboardCookieImportDebugLog = nil self.logOpenAIWeb("[\(stamp)] OpenAI web \(context) start") } private func logOpenAIWeb(_ message: String) { let safeMessage = LogRedactor.redact(message) self.openAIWebLogger.debug(safeMessage) self.openAIWebDebugLines.append(safeMessage) if self.openAIWebDebugLines.count > 240 { self.openAIWebDebugLines.removeFirst(self.openAIWebDebugLines.count - 240) } self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n") } func resetOpenAIWebState() { self.openAIDashboard = nil self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardTargetEmail = nil self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil self.openAIDashboardCookieImportDebugLog = nil self.lastOpenAIDashboardCookieImportAttemptAt = nil self.lastOpenAIDashboardCookieImportEmail = nil } private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool { guard let expected, !expected.isEmpty else { return false } guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false } return raw.lowercased() != expected.lowercased() } func codexAccountEmailForOpenAIDashboard() -> String? { let direct = self.snapshots[.codex]?.accountEmail(for: .codex)? .trimmingCharacters(in: .whitespacesAndNewlines) if let direct, !direct.isEmpty { return direct } let fallback = self.codexFetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines) if let fallback, !fallback.isEmpty { return fallback } let cached = self.openAIDashboard?.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if let cached, !cached.isEmpty { return cached } let imported = self.lastOpenAIDashboardCookieImportEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if let imported, !imported.isEmpty { return imported } return nil } } extension UsageStore { func debugDumpClaude() async { let fetcher = ClaudeUsageFetcher( browserDetection: self.browserDetection, keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) let output = await fetcher.debugRawProbe(model: "sonnet") let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("codexbar-claude-probe.txt") try? output.write(to: url, atomically: true, encoding: .utf8) await MainActor.run { let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ") self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))" NSWorkspace.shared.open(url) } } func dumpLog(toFileFor provider: UsageProvider) async -> URL? { let text = await self.debugLog(for: provider) let filename = "codexbar-\(provider.rawValue)-probe.txt" let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename) do { try text.write(to: url, atomically: true, encoding: .utf8) _ = await MainActor.run { NSWorkspace.shared.open(url) } return url } catch { await MainActor.run { self.errors[provider] = "Failed to save log: \(error.localizedDescription)" } return nil } } func debugAugmentDump() async -> String { await AugmentStatusProbe.latestDumps() } func debugLog(for provider: UsageProvider) async -> String { if let cached = self.probeLogs[provider], !cached.isEmpty { return cached } let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled let claudeUsageDataSource = self.settings.claudeUsageDataSource let claudeCookieSource = self.settings.claudeCookieSource let claudeCookieHeader = self.settings.claudeCookieHeader let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude { await self.makeClaudeDebugConfiguration( fallbackUsageDataSource: claudeUsageDataSource, fallbackWebExtrasEnabled: claudeWebExtrasEnabled, fallbackCookieSource: claudeCookieSource, fallbackCookieHeader: claudeCookieHeader) } else { nil } let cursorCookieSource = self.settings.cursorCookieSource let cursorCookieHeader = self.settings.cursorCookieHeader let ampCookieSource = self.settings.ampCookieSource let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader let processEnvironment = ProcessInfo.processInfo.environment let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() let text = await Task.detached(priority: .utility) { () -> String in let unimplementedDebugLogMessages: [UsageProvider: String] = [ .gemini: "Gemini debug log not yet implemented", .antigravity: "Antigravity debug log not yet implemented", .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .vertexai: "Vertex AI debug log not yet implemented", .kilo: "Kilo debug log not yet implemented", .kiro: "Kiro debug log not yet implemented", .kimi: "Kimi debug log not yet implemented", .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", ] let buildText = { switch provider { case .codex: return await codexFetcher.debugRawRateLimits() case .claude: guard let claudeDebugConfiguration else { return "Claude debug log configuration unavailable" } return await claudeDebugExecutionContext.apply { await Self.debugClaudeLog( browserDetection: browserDetection, configuration: claudeDebugConfiguration) } case .zai: let resolution = ProviderTokenResolver.zaiResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .synthetic: let resolution = ProviderTokenResolver.syntheticResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .cursor: return await Self.debugCursorLog( browserDetection: browserDetection, cursorCookieSource: cursorCookieSource, cursorCookieHeader: cursorCookieHeader) case .minimax: let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() let tokenSource = tokenResolution?.source.rawValue ?? "none" let cookieSource = cookieResolution?.source.rawValue ?? "none" return "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + "source=\(cookieSource)" case .alibaba: let resolution = ProviderTokenResolver.alibabaTokenResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .augment: return await Self.debugAugmentLog() case .amp: return await Self.debugAmpLog( browserDetection: browserDetection, ampCookieSource: ampCookieSource, ampCookieHeader: ampCookieHeader) case .ollama: return await Self.debugOllamaLog( browserDetection: browserDetection, ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) let hasAny = resolution != nil let source: String = if resolution == nil { "none" } else if openRouterHasConfigToken, openRouterHasEnvToken { "settings-config (overrides env)" } else if openRouterHasConfigToken { "settings-config" } else { resolution?.source.rawValue ?? "environment" } return "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .warp: let resolution = ProviderTokenResolver.warpResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } return await claudeDebugExecutionContext.apply { await buildText() } }.value self.probeLogs[provider] = text return text } private func makeClaudeDebugConfiguration( fallbackUsageDataSource: ClaudeUsageDataSource, fallbackWebExtrasEnabled: Bool, fallbackCookieSource: ProviderCookieSource, fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration { await MainActor.run { let sourceMode = self.sourceMode(for: .claude) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) let environment = ProviderRegistry.makeEnvironment( base: ProcessInfo.processInfo.environment, provider: .claude, settings: self.settings, tokenOverride: nil) let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings( usageDataSource: fallbackUsageDataSource, webExtrasEnabled: fallbackWebExtrasEnabled, cookieSource: fallbackCookieSource, manualCookieHeader: fallbackCookieHeader) return ClaudeDebugLogConfiguration( runtime: CodexBarCore.ProviderRuntime.app, sourceMode: sourceMode, environment: environment, webExtrasEnabled: claudeSettings.webExtrasEnabled, usageDataSource: claudeSettings.usageDataSource, cookieSource: claudeSettings.cookieSource, cookieHeader: claudeSettings.manualCookieHeader ?? "", keepCLISessionsAlive: snapshot.debugKeepCLISessionsAlive) } } private struct ClaudeDebugExecutionContext { let interaction: ProviderInteraction let refreshPhase: ProviderRefreshPhase #if DEBUG let keychainServiceOverride: String? let credentialsURLOverride: URL? let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore? let keychainPromptModeOverride: ClaudeOAuthKeychainPromptMode? let keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategy? let cliPathOverride: String? let statusFetchOverride: ClaudeStatusProbe.FetchOverride? #endif func apply(_ operation: () async -> T) async -> T { await ProviderInteractionContext.$current.withValue(self.interaction) { await ProviderRefreshContext.$current.withValue(self.refreshPhase) { #if DEBUG return await KeychainCacheStore.withServiceOverrideForTesting(self.keychainServiceOverride) { await ClaudeOAuthCredentialsStore .withCredentialsURLOverrideForTesting(self.credentialsURLOverride) { await ClaudeOAuthCredentialsStore .withTestingOverridesSnapshotForTask(self.testingOverrides) { await ClaudeOAuthKeychainAccessGate .withDeniedUntilStoreOverrideForTesting(self .keychainDeniedUntilStoreOverride) { await ClaudeOAuthKeychainPromptPreference .withTaskOverrideForTesting(self.keychainPromptModeOverride) { await ClaudeOAuthKeychainReadStrategyPreference .withTaskOverrideForTesting(self .keychainReadStrategyOverride) { await ClaudeCLIResolver .withResolvedBinaryPathOverrideForTesting(self .cliPathOverride) { await ClaudeStatusProbe .withFetchOverrideForTesting(self .statusFetchOverride) { await operation() } } } } } } } } #else return await operation() #endif } } } } private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext { #if DEBUG ClaudeDebugExecutionContext( interaction: ProviderInteractionContext.current, refreshPhase: ProviderRefreshContext.current, keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting, keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting, cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) #else ClaudeDebugExecutionContext( interaction: ProviderInteractionContext.current, refreshPhase: ProviderRefreshContext.current) #endif } private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, cursorCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { var lines: [String] = [] do { let probe = CursorStatusProbe(browserDetection: browserDetection) let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual, let normalizedHeader = CookieHeaderNormalizer .normalize(cursorCookieHeader) { try await probe.fetchWithManualCookies(normalizedHeader) } else { try await probe.fetch { msg in lines.append("[cursor-cookie] \(msg)") } } lines.append("") lines.append("Cursor Status Summary:") lines.append("membershipType=\(snapshot.membershipType ?? "nil")") lines.append("accountEmail=\(snapshot.accountEmail ?? "nil")") lines.append("planPercentUsed=\(snapshot.planPercentUsed)%") lines.append("planUsedUSD=$\(snapshot.planUsedUSD)") lines.append("planLimitUSD=$\(snapshot.planLimitUSD)") lines.append("onDemandUsedUSD=$\(snapshot.onDemandUsedUSD)") lines.append("onDemandLimitUSD=\(snapshot.onDemandLimitUSD.map { "$\($0)" } ?? "nil")") if let teamUsed = snapshot.teamOnDemandUsedUSD { lines.append("teamOnDemandUsedUSD=$\(teamUsed)") } if let teamLimit = snapshot.teamOnDemandLimitUSD { lines.append("teamOnDemandLimitUSD=$\(teamLimit)") } lines.append("billingCycleEnd=\(snapshot.billingCycleEnd?.description ?? "nil")") if let rawJSON = snapshot.rawJSON { lines.append("") lines.append("Raw API Response:") lines.append(rawJSON) } return lines.joined(separator: "\n") } catch { lines.append("") lines.append("Cursor probe failed: \(error.localizedDescription)") return lines.joined(separator: "\n") } } } private static func debugAugmentLog() async -> String { await runWithTimeout(seconds: 15) { let probe = AugmentStatusProbe() return await probe.debugRawProbe() } } private static func debugAmpLog( browserDetection: BrowserDetection, ampCookieSource: ProviderCookieSource, ampCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { let fetcher = AmpUsageFetcher(browserDetection: browserDetection) let manualHeader = ampCookieSource == .manual ? CookieHeaderNormalizer.normalize(ampCookieHeader) : nil return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader) } } private static func debugOllamaLog( browserDetection: BrowserDetection, ollamaCookieSource: ProviderCookieSource, ollamaCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { let fetcher = OllamaUsageFetcher(browserDetection: browserDetection) let manualHeader = ollamaCookieSource == .manual ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) : nil return await fetcher.debugRawProbe( cookieHeaderOverride: manualHeader, manualCookieMode: ollamaCookieSource == .manual) } } private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection Task { @MainActor [weak self] in let resolved = await Task.detached { () -> [UsageProvider: String] in var resolved: [UsageProvider: String] = [:] await withTaskGroup(of: (UsageProvider, String?).self) { group in for implementation in implementations { let context = ProviderVersionContext( provider: implementation.id, browserDetection: browserDetection) group.addTask { await (implementation.id, implementation.detectVersion(context: context)) } } for await (provider, version) in group { guard let version, !version.isEmpty else { continue } resolved[provider] = version } } return resolved }.value self?.versions = resolved } } @MainActor private func schedulePathDebugInfoRefresh() { self.pathDebugRefreshTask?.cancel() self.pathDebugRefreshTask = Task { [weak self] in do { try await Task.sleep(nanoseconds: 150_000_000) } catch { return } await self?.refreshPathDebugInfo() } } private func runBackgroundSnapshot( _ snapshot: @escaping @Sendable () async -> PathDebugSnapshot) async { let result = await snapshot() await MainActor.run { self.pathDebugInfo = result } } private func refreshPathDebugInfo() async { await self.runBackgroundSnapshot { await PathBuilder.debugSnapshotAsync(purposes: [.rpc, .tty, .nodeTooling]) } } func clearCostUsageCache() async -> String? { let errorMessage: String? = await Task.detached(priority: .utility) { let fm = FileManager.default let cacheDirs = [ Self.costUsageCacheDirectory(fileManager: fm), ] for cacheDir in cacheDirs { do { try fm.removeItem(at: cacheDir) } catch let error as NSError { if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue } return error.localizedDescription } } return nil }.value guard errorMessage == nil else { return errorMessage } self.tokenSnapshots.removeAll() self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() return nil } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard provider == .codex || provider == .claude || provider == .vertexai else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) return } guard self.settings.costUsageEnabled else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) return } guard self.isEnabled(provider) else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) return } guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() if !force, let last = self.lastTokenFetchAt[provider], now.timeIntervalSince(last) < self.tokenFetchTTL { return } self.lastTokenFetchAt[provider] = now self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in group.addTask(priority: .utility) { try await fetcher.loadTokenSnapshot( provider: provider, now: now, forceRefresh: force, allowVertexClaudeFallback: !self.isEnabled(.claude)) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) throw CostUsageError.timedOut(seconds: Int(timeoutSeconds)) } defer { group.cancelAll() } guard let snapshot = try await group.next() else { throw CancellationError() } return snapshot } guard !snapshot.daily.isEmpty else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return } let duration = Date().timeIntervalSince(startedAt) let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—" let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—" let durationText = String(format: "%.2f", duration) let message = "cost usage success provider=\(providerText) " + "duration=\(durationText)s " + "today=\(sessionCost) " + "30d=\(monthCost)" self.tokenCostLogger.info(message) self.tokenSnapshots[provider] = snapshot self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { if error is CancellationError { return } let duration = Date().timeIntervalSince(startedAt) let msg = error.localizedDescription let durationText = String(format: "%.2f", duration) let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)" self.tokenCostLogger.error(message) let hadPriorData = self.tokenSnapshots[provider] != nil let shouldSurface = self.tokenFailureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.tokenErrors[provider] = error.localizedDescription self.tokenSnapshots.removeValue(forKey: provider) } else { self.tokenErrors[provider] = nil } } } } ================================================ FILE: Sources/CodexBar/UsageStoreSupport.swift ================================================ import CodexBarCore import Foundation enum ProviderStatusIndicator: String { case none case minor case major case critical case maintenance case unknown var hasIssue: Bool { switch self { case .none: false default: true } } var label: String { switch self { case .none: "Operational" case .minor: "Partial outage" case .major: "Major outage" case .critical: "Critical issue" case .maintenance: "Maintenance" case .unknown: "Status unknown" } } } struct ProviderStatus { let indicator: ProviderStatusIndicator let description: String? let updatedAt: Date? } /// Tracks consecutive failures so we can ignore a single flake when we previously had fresh data. struct ConsecutiveFailureGate { private(set) var streak: Int = 0 mutating func recordSuccess() { self.streak = 0 } mutating func reset() { self.streak = 0 } /// Returns true when the caller should surface the error to the UI. mutating func shouldSurfaceError(onFailureWithPriorData hadPriorData: Bool) -> Bool { self.streak += 1 if hadPriorData, self.streak == 1 { return false } return true } } #if DEBUG extension UsageStore { func _setSnapshotForTesting(_ snapshot: UsageSnapshot?, provider: UsageProvider) { self.snapshots[provider] = snapshot?.scoped(to: provider) } func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) { self.tokenSnapshots[provider] = snapshot } func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) { self.tokenErrors[provider] = error } func _setErrorForTesting(_ error: String?, provider: UsageProvider) { self.errors[provider] = error } func _setCodexHistoricalDatasetForTesting(_ dataset: CodexHistoricalDataset?, accountKey: String? = nil) { self.codexHistoricalDataset = dataset self.codexHistoricalDatasetAccountKey = accountKey self.historicalPaceRevision += 1 } } #endif ================================================ FILE: Sources/CodexBar/ZaiTokenStore.swift ================================================ import CodexBarCore import Foundation import Security protocol ZaiTokenStoring: Sendable { func loadToken() throws -> String? func storeToken(_ token: String?) throws } enum ZaiTokenStoreError: LocalizedError { case keychainStatus(OSStatus) case invalidData var errorDescription: String? { switch self { case let .keychainStatus(status): "Keychain error: \(status)" case .invalidData: "Keychain returned invalid data." } } } struct KeychainZaiTokenStore: ZaiTokenStoring { private static let log = CodexBarLog.logger(LogCategories.zaiTokenStore) private let service = "com.steipete.CodexBar" private let account = "zai-api-token" // Cache to reduce keychain access frequency private nonisolated(unsafe) static var cachedToken: String? private nonisolated(unsafe) static var cacheTimestamp: Date? private static let cacheLock = NSLock() private static let cacheTTL: TimeInterval = 1800 // 30 minutes func loadToken() throws -> String? { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token load") return nil } // Check cache first Self.cacheLock.lock() if let timestamp = Self.cacheTimestamp, Date().timeIntervalSince(timestamp) < Self.cacheTTL { let cached = Self.cachedToken Self.cacheLock.unlock() Self.log.debug("Using cached Zai token") return cached } Self.cacheLock.unlock() var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if case .interactionRequired = KeychainAccessPreflight .checkGenericPassword(service: self.service, account: self.account) { KeychainPromptHandler.handler?(KeychainPromptContext( kind: .zaiToken, service: self.service, account: self.account)) } let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() Self.cachedToken = nil Self.cacheTimestamp = Date() Self.cacheLock.unlock() return nil } guard status == errSecSuccess else { Self.log.error("Keychain read failed: \(status)") throw ZaiTokenStoreError.keychainStatus(status) } guard let data = result as? Data else { throw ZaiTokenStoreError.invalidData } let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) let finalValue = (token?.isEmpty == false) ? token : nil // Cache the result Self.cacheLock.lock() Self.cachedToken = finalValue Self.cacheTimestamp = Date() Self.cacheLock.unlock() return finalValue } func storeToken(_ token: String?) throws { guard !KeychainAccessGate.isDisabled else { Self.log.debug("Keychain access disabled; skipping token store") return } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() // Invalidate cache Self.cacheLock.lock() Self.cachedToken = nil Self.cacheTimestamp = nil Self.cacheLock.unlock() return } let data = cleaned!.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() Self.cachedToken = cleaned Self.cacheTimestamp = Date() Self.cacheLock.unlock() return } if updateStatus != errSecItemNotFound { Self.log.error("Keychain update failed: \(updateStatus)") throw ZaiTokenStoreError.keychainStatus(updateStatus) } var addQuery = query for (key, value) in attributes { addQuery[key] = value } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw ZaiTokenStoreError.keychainStatus(addStatus) } // Update cache Self.cacheLock.lock() Self.cachedToken = cleaned Self.cacheTimestamp = Date() Self.cacheLock.unlock() } private func deleteTokenIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() Self.cachedToken = nil Self.cacheTimestamp = nil Self.cacheLock.unlock() return } Self.log.error("Keychain delete failed: \(status)") throw ZaiTokenStoreError.keychainStatus(status) } } ================================================ FILE: Sources/CodexBarCLI/CLIConfigCommand.swift ================================================ import CodexBarCore import Commander import Foundation extension CodexBarCLI { static func runConfigValidate(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) let config = Self.loadConfig(output: output) let issues = CodexBarConfigValidator.validate(config) let hasErrors = issues.contains(where: { $0.severity == .error }) switch output.format { case .text: if issues.isEmpty { print("Config: OK") } else { for issue in issues { let provider = issue.provider?.rawValue ?? "config" let field = issue.field ?? "" let prefix = "[\(issue.severity.rawValue.uppercased())]" let suffix = field.isEmpty ? "" : " (\(field))" print("\(prefix) \(provider)\(suffix): \(issue.message)") } } case .json: Self.printJSON(issues, pretty: output.pretty) } Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .config) } static func runConfigDump(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) let config = Self.loadConfig(output: output) Self.printJSON(config, pretty: output.pretty) Self.exit(code: .success, output: output, kind: .config) } } struct ConfigOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false @Flag(name: .long("json-output"), help: "Emit machine-readable logs") var jsonOutput: Bool = false @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") var logLevel: String? @Option(name: .long("format"), help: "Output format: text | json") var format: OutputFormat? @Flag(name: .long("json"), help: "") var jsonShortcut: Bool = false @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") var jsonOnly: Bool = false @Flag(name: .long("pretty"), help: "Pretty-print JSON output") var pretty: Bool = false } ================================================ FILE: Sources/CodexBarCLI/CLICostCommand.swift ================================================ import CodexBarCore import Commander import Foundation extension CodexBarCLI { private static let costSupportedProviders: Set = [.claude, .codex] static func runCost(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) let config = CodexBarCLI.loadConfig(output: output) let selection = CodexBarCLI.decodeProvider(from: values, config: config) let providers = Self.costProviders(from: selection) let unsupported = selection.asList.filter { !Self.costSupportedProviders.contains($0) } if !unsupported.isEmpty { let names = unsupported .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } .sorted() .joined(separator: ", ") if !output.jsonOnly { Self.writeStderr("Skipping providers without local cost usage: \(names)\n") } } guard !providers.isEmpty else { Self.exit( code: .failure, message: "Error: cost is only supported for Claude and Codex.", output: output, kind: .args) } let format = output.format let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let fetcher = CostUsageFetcher() var sections: [String] = [] var payload: [CostPayload] = [] var exitCode: ExitCode = .success for provider in providers { do { // Cost usage is local-only; it does not require web/CLI provider fetches. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh) switch format { case .text: sections.append(Self.renderCostText(provider: provider, snapshot: snapshot, useColor: useColor)) case .json: payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) } } catch { exitCode = Self.mapError(error) if format == .json { payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) } else if !output.jsonOnly { Self.writeStderr("Error: \(error.localizedDescription)\n") } } } switch format { case .text: if !sections.isEmpty { print(sections.joined(separator: "\n\n")) } case .json: if !payload.isEmpty { Self.printJSON(payload, pretty: output.pretty) } } Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) } static func renderCostText( provider: UsageProvider, snapshot: CostUsageTokenSnapshot, useColor: Bool) -> String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let header = Self.costHeaderLine("\(name) Cost (local)", useColor: useColor) let todayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)" let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) } let monthLine = monthTokens.map { "Last 30 days: \(monthCost) · \($0) tokens" } ?? "Last 30 days: \(monthCost)" return [header, todayLine, monthLine].joined(separator: "\n") } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { guard useColor else { return header } return "\u{001B}[1;36m\(header)\u{001B}[0m" } private static func costProviders(from selection: ProviderSelection) -> [UsageProvider] { selection.asList.filter { Self.costSupportedProviders.contains($0) } } private static func makeCostPayload( provider: UsageProvider, snapshot: CostUsageTokenSnapshot?, error: Error?) -> CostPayload { let daily = snapshot?.daily.map { entry in CostDailyEntryPayload( date: entry.date, inputTokens: entry.inputTokens, outputTokens: entry.outputTokens, cacheReadTokens: entry.cacheReadTokens, cacheCreationTokens: entry.cacheCreationTokens, totalTokens: entry.totalTokens, costUSD: entry.costUSD, modelsUsed: entry.modelsUsed, modelBreakdowns: entry.modelBreakdowns?.map { breakdown in CostModelBreakdownPayload( modelName: breakdown.modelName, costUSD: breakdown.costUSD, totalTokens: breakdown.totalTokens) }) } ?? [] return CostPayload( provider: provider.rawValue, source: "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), sessionTokens: snapshot?.sessionTokens, sessionCostUSD: snapshot?.sessionCostUSD, last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, daily: daily, totals: snapshot.flatMap(Self.costTotals(from:)), error: error.map { Self.makeErrorPayload($0) }) } private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? { let entries = snapshot.daily guard !entries.isEmpty else { guard snapshot.last30DaysTokens != nil || snapshot.last30DaysCostUSD != nil else { return nil } return CostTotalsPayload( totalInputTokens: nil, totalOutputTokens: nil, cacheReadTokens: nil, cacheCreationTokens: nil, totalTokens: snapshot.last30DaysTokens, totalCostUSD: snapshot.last30DaysCostUSD) } var totalInput = 0 var totalOutput = 0 var totalCacheRead = 0 var totalCacheCreation = 0 var totalTokens = 0 var totalCost = 0.0 var sawInput = false var sawOutput = false var sawCacheRead = false var sawCacheCreation = false var sawTokens = false var sawCost = false for entry in entries { if let input = entry.inputTokens { totalInput += input sawInput = true } if let output = entry.outputTokens { totalOutput += output sawOutput = true } if let cacheRead = entry.cacheReadTokens { totalCacheRead += cacheRead sawCacheRead = true } if let cacheCreation = entry.cacheCreationTokens { totalCacheCreation += cacheCreation sawCacheCreation = true } if let tokens = entry.totalTokens { totalTokens += tokens sawTokens = true } if let cost = entry.costUSD { totalCost += cost sawCost = true } } // Prefer totals derived from daily rows; fall back to snapshot aggregates when rows omit fields. return CostTotalsPayload( totalInputTokens: sawInput ? totalInput : nil, totalOutputTokens: sawOutput ? totalOutput : nil, cacheReadTokens: sawCacheRead ? totalCacheRead : nil, cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil, totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens, totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD) } } struct CostOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false @Flag(name: .long("json-output"), help: "Emit machine-readable logs") var jsonOutput: Bool = false @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") var logLevel: String? @Option( name: .long("provider"), help: ProviderHelp.optionHelp) var provider: ProviderSelection? @Option(name: .long("format"), help: "Output format: text | json") var format: OutputFormat? @Flag(name: .long("json"), help: "") var jsonShortcut: Bool = false @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") var jsonOnly: Bool = false @Flag(name: .long("pretty"), help: "Pretty-print JSON output") var pretty: Bool = false @Flag(name: .long("no-color"), help: "Disable ANSI colors in text output") var noColor: Bool = false @Flag(name: .long("refresh"), help: "Force refresh by ignoring cached scans") var refresh: Bool = false } struct CostPayload: Encodable { let provider: String let source: String let updatedAt: Date? let sessionTokens: Int? let sessionCostUSD: Double? let last30DaysTokens: Int? let last30DaysCostUSD: Double? let daily: [CostDailyEntryPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? } struct CostDailyEntryPayload: Encodable { let date: String let inputTokens: Int? let outputTokens: Int? let cacheReadTokens: Int? let cacheCreationTokens: Int? let totalTokens: Int? let costUSD: Double? let modelsUsed: [String]? let modelBreakdowns: [CostModelBreakdownPayload]? private enum CodingKeys: String, CodingKey { case date case inputTokens case outputTokens case cacheReadTokens case cacheCreationTokens case totalTokens case costUSD = "totalCost" case modelsUsed case modelBreakdowns } } struct CostModelBreakdownPayload: Encodable { let modelName: String let costUSD: Double? let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD = "cost" case totalTokens } } struct CostTotalsPayload: Encodable { let totalInputTokens: Int? let totalOutputTokens: Int? let cacheReadTokens: Int? let cacheCreationTokens: Int? let totalTokens: Int? let totalCostUSD: Double? private enum CodingKeys: String, CodingKey { case totalInputTokens = "inputTokens" case totalOutputTokens = "outputTokens" case cacheReadTokens case cacheCreationTokens case totalTokens case totalCostUSD = "totalCost" } } // Intentionally empty. ================================================ FILE: Sources/CodexBarCLI/CLIEntry.swift ================================================ import CodexBarCore import Commander #if canImport(AppKit) import AppKit #endif #if canImport(Darwin) import Darwin #else import Glibc #endif import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif @main enum CodexBarCLI { static func main() async { let rawArgv = Array(CommandLine.arguments.dropFirst()) let argv = Self.effectiveArgv(rawArgv) let outputPreferences = CLIOutputPreferences.from(argv: argv) // Fast path: global help/version before building descriptors. if let helpIndex = argv.firstIndex(where: { $0 == "-h" || $0 == "--help" }) { let command = helpIndex == 0 ? argv.dropFirst().first : argv.first Self.printHelp(for: command) } if argv.contains("-V") || argv.contains("--version") { Self.printVersion() } let program = Program(descriptors: Self.commandDescriptors()) do { let invocation = try program.resolve(argv: argv) Self.bootstrapLogging(values: invocation.parsedValues) switch invocation.path { case ["usage"]: await self.runUsage(invocation.parsedValues) case ["cost"]: await self.runCost(invocation.parsedValues) case ["config", "validate"]: self.runConfigValidate(invocation.parsedValues) case ["config", "dump"]: self.runConfigDump(invocation.parsedValues) default: Self.exit( code: .failure, message: "Unknown command", output: outputPreferences, kind: .args) } } catch let error as CommanderProgramError { Self.exit(code: .failure, message: error.description, output: outputPreferences, kind: .args) } catch { Self.exit(code: .failure, message: error.localizedDescription, output: outputPreferences, kind: .runtime) } } private static func commandDescriptors() -> [CommandDescriptor] { let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) return [ CommandDescriptor( name: "usage", abstract: "Print usage as text or JSON", discussion: nil, signature: usageSignature), CommandDescriptor( name: "cost", abstract: "Print local cost usage as text or JSON", discussion: nil, signature: costSignature), CommandDescriptor( name: "config", abstract: "Config utilities", discussion: nil, signature: CommandSignature(), subcommands: [ CommandDescriptor( name: "validate", abstract: "Validate config file", discussion: nil, signature: configSignature), CommandDescriptor( name: "dump", abstract: "Print normalized config JSON", discussion: nil, signature: configSignature), ], defaultSubcommandName: "validate"), ] } // MARK: - Helpers private static func bootstrapLogging(values: ParsedValues) { let isJSON = values.flags.contains("jsonOutput") || values.flags.contains("jsonOnly") let verbose = values.flags.contains("verbose") let rawLevel = values.options["logLevel"]?.last let level = Self.resolvedLogLevel(verbose: verbose, rawLevel: rawLevel) CodexBarLog.bootstrapIfNeeded(.init(destination: .stderr, level: level, json: isJSON)) } static func resolvedLogLevel(verbose: Bool, rawLevel: String?) -> CodexBarLog.Level { CodexBarLog.parseLevel(rawLevel) ?? (verbose ? .debug : .error) } static func effectiveArgv(_ argv: [String]) -> [String] { guard let first = argv.first else { return ["usage"] } if first.hasPrefix("-") { return ["usage"] + argv } return argv } } ================================================ FILE: Sources/CodexBarCLI/CLIErrorReporting.swift ================================================ import CodexBarCore import Foundation enum CLIErrorKind: String, Encodable { case args case config case provider case runtime } struct ProviderErrorPayload: Encodable { let code: Int32 let message: String let kind: CLIErrorKind? } extension CodexBarCLI { static func makeErrorPayload(_ error: Error, kind: CLIErrorKind? = nil) -> ProviderErrorPayload { ProviderErrorPayload( code: self.mapError(error).rawValue, message: error.localizedDescription, kind: kind) } static func makeErrorPayload(code: ExitCode, message: String, kind: CLIErrorKind? = nil) -> ProviderErrorPayload { ProviderErrorPayload(code: code.rawValue, message: message, kind: kind) } static func makeCLIErrorPayload( message: String, code: ExitCode, kind: CLIErrorKind, pretty: Bool) -> String? { let payload = ProviderPayload( providerID: "cli", account: nil, version: nil, source: "cli", status: nil, usage: nil, credits: nil, antigravityPlanInfo: nil, openaiDashboard: nil, error: ProviderErrorPayload(code: code.rawValue, message: message, kind: kind)) return self.encodeJSON([payload], pretty: pretty) } static func makeProviderErrorPayload( provider: UsageProvider, account: String?, source: String, status: ProviderStatusPayload?, error: Error, kind: CLIErrorKind = .provider) -> ProviderPayload { ProviderPayload( provider: provider, account: account, version: nil, source: source, status: status, usage: nil, credits: nil, antigravityPlanInfo: nil, openaiDashboard: nil, error: self.makeErrorPayload(error, kind: kind)) } static func encodeJSON(_ payload: some Encodable, pretty: Bool) -> String? { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = pretty ? [.prettyPrinted, .sortedKeys] : [] guard let data = try? encoder.encode(payload) else { return nil } return String(data: data, encoding: .utf8) } static func printJSON(_ payload: some Encodable, pretty: Bool) { if let output = self.encodeJSON(payload, pretty: pretty) { print(output) } } static func exit( code: ExitCode, message: String? = nil, output: CLIOutputPreferences? = nil, kind: CLIErrorKind = .runtime) -> Never { if code != .success { if let output, output.usesJSONOutput { let payload = self.makeCLIErrorPayload( message: message ?? "Error", code: code, kind: kind, pretty: output.pretty) if let payload { print(payload) } } else if let message { self.writeStderr("\(message)\n") } } platformExit(code.rawValue) } static func printError(_ error: Error, output: CLIOutputPreferences, kind: CLIErrorKind = .runtime) { if output.usesJSONOutput { let payload = ProviderPayload( providerID: "cli", account: nil, version: nil, source: "cli", status: nil, usage: nil, credits: nil, antigravityPlanInfo: nil, openaiDashboard: nil, error: self.makeErrorPayload(error, kind: kind)) self.printJSON([payload], pretty: output.pretty) } else { self.writeStderr("Error: \(error.localizedDescription)\n") } } } ================================================ FILE: Sources/CodexBarCLI/CLIExitCode.swift ================================================ enum ExitCode: Int32 { case success = 0 case failure = 1 case binaryNotFound = 2 case parseError = 3 case timeout = 4 init(_ rawValue: Int) { self = ExitCode(rawValue: Int32(rawValue)) ?? .failure } } ================================================ FILE: Sources/CodexBarCLI/CLIHelp.swift ================================================ import CodexBarCore import Foundation extension CodexBarCLI { static func usageHelp(version: String) -> String { """ CodexBar \(version) Usage: codexbar usage [--format text|json] [--json] [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--account