From bd2a8ae842e060167e0a1bfc813212e8f8b422d6 Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 01:04:03 +0800 Subject: [PATCH 1/8] Enhance localization support and menu functionality - Added default localization to the package configuration. - Introduced resource processing for menu items. - Updated various UI elements in the menu card to use localized strings for better internationalization. - Implemented new settings for CLIProxy API, including base URL and management key. - Enhanced the settings pane with language selection and CLIProxy configuration options. - Improved handling of account actions in the menu based on provider settings. --- ...usItemController+Menu_20260208235111.swift | 1452 +++++++++++++++++ ...usItemController+Menu_20260208235623.swift | 1452 +++++++++++++++++ Package.swift | 4 + Scripts/package_app.sh | 4 + Sources/CodexBar/MenuCardView.swift | 58 +- Sources/CodexBar/MenuDescriptor.swift | 32 +- ...penAICreditsPurchaseWindowController.swift | 2 +- Sources/CodexBar/PreferencesGeneralPane.swift | 171 +- .../CodexBar/PreferencesProvidersPane.swift | 30 +- .../Codex/CodexProviderImplementation.swift | 54 +- .../Providers/Codex/CodexSettingsStore.swift | 57 +- Sources/CodexBar/SettingsStore+Defaults.swift | 35 + Sources/CodexBar/SettingsStore.swift | 61 +- Sources/CodexBar/SettingsStoreState.swift | 4 + .../StatusItemController+Actions.swift | 19 +- .../StatusItemController+Animation.swift | 20 - .../CodexBar/StatusItemController+Menu.swift | 263 ++- Sources/CodexBar/UsageStore+Refresh.swift | 232 +++ Sources/CodexBar/UsageStore.swift | 9 + Sources/CodexBarCLI/CLIUsageCommand.swift | 82 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 22 +- .../CodexBarCore/Config/CodexBarConfig.swift | 14 + .../Config/CodexBarConfigValidation.swift | 24 + Sources/CodexBarCore/Localization.swift | 41 + .../OpenAIWeb/OpenAIDashboardFetcher.swift | 19 +- .../Codex/CodexCLIProxyManagementClient.swift | 306 ++++ .../Codex/CodexCLIProxySettings.swift | 63 + .../Codex/CodexProviderDescriptor.swift | 113 +- .../Providers/Codex/CodexStatusProbe.swift | 17 +- .../Codex/CodexUsageDataSource.swift | 14 +- .../Codex/CodexUsageSnapshotMapper.swift | 46 + .../Providers/ProviderSettingsSnapshot.swift | 11 +- .../Resources/en.lproj/Localizable.strings | 154 ++ .../zh-Hans.lproj/Localizable.strings | 154 ++ Sources/CodexBarCore/UsageFetcher.swift | 27 +- Tests/CodexBarTests/MenuCardModelTests.swift | 10 + .../ProviderSettingsDescriptorTests.swift | 44 + Tests/CodexBarTests/SettingsStoreTests.swift | 30 + docs/codex.md | 18 +- 39 files changed, 4980 insertions(+), 188 deletions(-) create mode 100644 .history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift create mode 100644 .history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift create mode 100644 Sources/CodexBarCore/Localization.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift create mode 100644 Sources/CodexBarCore/Resources/en.lproj/Localizable.strings create mode 100644 Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings diff --git a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift new file mode 100644 index 000000000..6d5872ab5 --- /dev/null +++ b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift @@ -0,0 +1,1452 @@ +import AppKit +import CodexBarCore +import Observation +import QuartzCore +import SwiftUI + +// MARK: - NSMenu construction + +extension StatusItemController { + private static let menuCardBaseWidth: CGFloat = 310 + 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 { + self.selectedMenuProvider = self.resolvedMenuProvider() + self.lastMenuProvider = self.selectedMenuProvider ?? .codex + provider = self.selectedMenuProvider + } else { + if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { + self.lastMenuProvider = menuProvider + provider = menuProvider + } else if menu === self.fallbackMenu { + self.lastMenuProvider = self.store.enabledProviders().first ?? .codex + provider = nil + } else { + let resolved = self.store.enabledProviders().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 selectedProvider = provider + let enabledProviders = self.store.enabledProviders() + let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) + let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex + let tokenAccountDisplay = 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 canSmartUpdate = self.shouldMergeIcons && + enabledProviders.count > 1 && + switcherProvidersMatch && + 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) + + self.addProviderSwitcherIfNeeded( + to: menu, + enabledProviders: enabledProviders, + selectedProvider: selectedProvider) + // Track which providers the switcher was built with for smart update detection + if self.shouldMergeIcons, enabledProviders.count > 1 { + self.lastSwitcherProviders = enabledProviders + } + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + let menuContext = MenuCardContext( + currentProvider: currentProvider, + selectedProvider: selectedProvider, + menuWidth: menuWidth, + tokenAccountDisplay: tokenAccountDisplay, + 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) + } + + /// 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) + } + + 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], + selectedProvider: UsageProvider?) + { + guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } + let switcherItem = self.makeProviderSwitcherItem( + providers: enabledProviders, + selected: selectedProvider, + 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()) + } + + private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { + let accountSnapshots = tokenAccountDisplay.snapshots + let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( + provider: context.currentProvider, + display: tokenAccountDisplay) + if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: aggregateModel, width: context.menuWidth), + id: "menuCard-aggregate", + width: context.menuWidth)) + if !accountSnapshots.isEmpty { + menu.addItem(.separator()) + } + } + if shouldShowAggregateCard { + let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) + if !entries.isEmpty { + let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) + menu.addItem(self.makeMenuCardItem( + compactView, + id: "menuCard-auth-grid", + width: context.menuWidth)) + menu.addItem(.separator()) + } + return false + } + + let cards = accountSnapshots.isEmpty + ? [] + : accountSnapshots.compactMap { accountSnapshot in + self.menuCardModel( + for: context.currentProvider, + snapshotOverride: accountSnapshot.snapshot, + errorOverride: accountSnapshot.error) + } + + if cards.isEmpty, !shouldShowAggregateCard, 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) { + 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): + 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()) + } + } + } + + 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], + selected: UsageProvider?, + menu: NSMenu) -> NSMenuItem + { + let view = ProviderSwitcherView( + providers: providers, + selected: selected, + 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] provider in + guard let self, let menu else { return } + 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.enabledProviders(), 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 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() -> UsageProvider? { + let enabled = self.store.enabledProviders() + if enabled.isEmpty { return .codex } + if let selected = self.selectedMenuProvider, enabled.contains(selected) { + return selected + } + return enabled.first + } + + private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { + if provider == .codex, + let snapshots = self.store.accountSnapshots[provider], + snapshots.count > 1, + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + { + return TokenAccountMenuDisplay( + provider: provider, + accounts: snapshots.map(\.account), + snapshots: snapshots, + activeIndex: 0, + showAll: true, + showSwitcher: false) + } + + 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 isCodexCLIProxyMultiAuthDisplay( + provider: UsageProvider, + display: TokenAccountMenuDisplay) -> Bool + { + provider == .codex && + display.showAll && + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + } + + private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { + snapshots.map { snapshot in + let primary = self.percent(for: snapshot.snapshot?.primary) + let secondary = self.percent(for: snapshot.snapshot?.secondary) + let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let accountTitle: String + if label.isEmpty { + accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" + } else { + accountTitle = label + } + return CodexCLIProxyAuthCompactGridView.Entry( + id: snapshot.id, + accountTitle: accountTitle, + primaryPercent: primary, + secondaryPercent: secondary, + hasError: snapshot.error != nil) + } + } + + private func percent(for window: RateWindow?) -> Double? { + guard let window else { return nil } + if self.settings.usageBarsShowUsed { + return max(0, min(100, window.usedPercent)) + } + return max(0, min(100, window.remainingPercent)) + } + + 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.selectedMenuProvider ?? self.resolvedMenuProvider() + } + if let provider = self.menuProviders[ObjectIdentifier(menu)] { + return provider + } + if menu === self.fallbackMenu { + return nil + } + return self.store.enabledProviders().first ?? .codex + } + + private func scheduleOpenMenuRefresh(for menu: NSMenu) { + // Kick off a background 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 } + let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() + let isStale = provider.map { self.store.isStale(provider: $0) } ?? self.store.isStale + let hasSnapshot = provider.map { self.store.snapshot(for: $0) != nil } ?? true + guard isStale || !hasSnapshot else { return } + self.refreshStore(forceTokenUsage: false) + } + } + + 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.enabledProviders(), 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) -> 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) + // 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 + let weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + 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 + 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) { + self.highlightState = highlightState + super.init(rootView: rootView) + } + + required init(rootView: Content) { + self.highlightState = MenuCardHighlightState() + super.init(rootView: rootView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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 struct CodexCLIProxyAuthCompactGridView: View { + struct Entry: Identifiable { + let id: UUID + let accountTitle: String + let primaryPercent: Double? + let secondaryPercent: Double? + let hasError: Bool + } + + let entries: [Entry] + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var columns: [GridItem] { + [ + GridItem(.flexible(minimum: 120), spacing: 8), + GridItem(.flexible(minimum: 120), spacing: 8), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let titleFormat = L10n.tr( + "menu.codex.cliproxy.auth_grid.title", + fallback: "Codex auth entries (%d)") + Text(String(format: titleFormat, locale: .current, self.entries.count)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + + LazyVGrid(columns: self.columns, spacing: 8) { + ForEach(self.entries) { entry in + AccountCell( + entry: entry, + isHighlighted: self.isHighlighted) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + private struct AccountCell: View { + let entry: CodexCLIProxyAuthCompactGridView.Entry + let isHighlighted: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.entry.accountTitle) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + + HStack(spacing: 12) { + RingBadge( + percent: self.entry.primaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemTeal), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + RingBadge( + percent: self.entry.secondaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemIndigo), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) + } + } + + private struct RingBadge: View { + let percent: Double? + let isError: Bool + let tint: Color + let isHighlighted: Bool + + private var normalizedPercent: Double { + guard let percent else { return 0 } + return max(0, min(100, percent)) + } + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let lineWidth = max(3, diameter * 0.11) + let fontSize = max(10, diameter * 0.32) + + ZStack { + Circle() + .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: self.normalizedPercent / 100) + .stroke( + MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) + + if self.isError { + Image(systemName: "xmark") + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + } else if self.percent == nil { + Text("—") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } else { + Text("\(Int(self.normalizedPercent.rounded()))") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + } + } + .frame(width: diameter, height: diameter) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .aspectRatio(1, contentMode: .fit) + } + } + + private func makeBuyCreditsItem() -> NSMenuItem { + let item = NSMenuItem( + title: L10n.tr("menu.action.buy_credits", fallback: "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: L10n.tr("menu.action.credits_history", fallback: "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: L10n.tr("menu.action.usage_breakdown", fallback: "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.enabledProviders() + 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.enabledProviders().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 input = UsageMenuCardView.Model.Input( + provider: target, + metadata: metadata, + sourceLabel: self.store.sourceLabel(for: target), + 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, + hidePersonalInfo: self.settings.hidePersonalInfo, + now: Date()) + return UsageMenuCardView.Model.make(input) + } + + @objc private func menuCardNoOp(_ sender: NSMenuItem) { + _ = sender + } + + 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 + } +} diff --git a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift new file mode 100644 index 000000000..09046cc45 --- /dev/null +++ b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift @@ -0,0 +1,1452 @@ +import AppKit +import CodexBarCore +import Observation +import QuartzCore +import SwiftUI + +// MARK: - NSMenu construction + +extension StatusItemController { + private static let menuCardBaseWidth: CGFloat = 310 + 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 { + self.selectedMenuProvider = self.resolvedMenuProvider() + self.lastMenuProvider = self.selectedMenuProvider ?? .codex + provider = self.selectedMenuProvider + } else { + if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { + self.lastMenuProvider = menuProvider + provider = menuProvider + } else if menu === self.fallbackMenu { + self.lastMenuProvider = self.store.enabledProviders().first ?? .codex + provider = nil + } else { + let resolved = self.store.enabledProviders().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 selectedProvider = provider + let enabledProviders = self.store.enabledProviders() + let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) + let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex + let tokenAccountDisplay = 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 canSmartUpdate = self.shouldMergeIcons && + enabledProviders.count > 1 && + switcherProvidersMatch && + 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) + + self.addProviderSwitcherIfNeeded( + to: menu, + enabledProviders: enabledProviders, + selectedProvider: selectedProvider) + // Track which providers the switcher was built with for smart update detection + if self.shouldMergeIcons, enabledProviders.count > 1 { + self.lastSwitcherProviders = enabledProviders + } + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + let menuContext = MenuCardContext( + currentProvider: currentProvider, + selectedProvider: selectedProvider, + menuWidth: menuWidth, + tokenAccountDisplay: tokenAccountDisplay, + 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) + } + + /// 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) + } + + 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], + selectedProvider: UsageProvider?) + { + guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } + let switcherItem = self.makeProviderSwitcherItem( + providers: enabledProviders, + selected: selectedProvider, + 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()) + } + + private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { + let accountSnapshots = tokenAccountDisplay.snapshots + let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( + provider: context.currentProvider, + display: tokenAccountDisplay) + if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: aggregateModel, width: context.menuWidth), + id: "menuCard-aggregate", + width: context.menuWidth)) + if !accountSnapshots.isEmpty { + menu.addItem(.separator()) + } + } + if shouldShowAggregateCard { + let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) + if !entries.isEmpty { + let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) + menu.addItem(self.makeMenuCardItem( + compactView, + id: "menuCard-auth-grid", + width: context.menuWidth)) + menu.addItem(.separator()) + } + return false + } + + let cards = accountSnapshots.isEmpty + ? [] + : accountSnapshots.compactMap { accountSnapshot in + self.menuCardModel( + for: context.currentProvider, + snapshotOverride: accountSnapshot.snapshot, + errorOverride: accountSnapshot.error) + } + + if cards.isEmpty, !shouldShowAggregateCard, 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) { + 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): + 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()) + } + } + } + + 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], + selected: UsageProvider?, + menu: NSMenu) -> NSMenuItem + { + let view = ProviderSwitcherView( + providers: providers, + selected: selected, + 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] provider in + guard let self, let menu else { return } + 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.enabledProviders(), 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 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() -> UsageProvider? { + let enabled = self.store.enabledProviders() + if enabled.isEmpty { return .codex } + if let selected = self.selectedMenuProvider, enabled.contains(selected) { + return selected + } + return enabled.first + } + + private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { + if provider == .codex, + let snapshots = self.store.accountSnapshots[provider], + snapshots.count > 1, + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + { + return TokenAccountMenuDisplay( + provider: provider, + accounts: snapshots.map(\.account), + snapshots: snapshots, + activeIndex: 0, + showAll: true, + showSwitcher: false) + } + + 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 isCodexCLIProxyMultiAuthDisplay( + provider: UsageProvider, + display: TokenAccountMenuDisplay) -> Bool + { + provider == .codex && + display.showAll && + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + } + + private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { + snapshots.map { snapshot in + let primary = self.percent(for: snapshot.snapshot?.primary) + let secondary = self.percent(for: snapshot.snapshot?.secondary) + let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let accountTitle: String + if label.isEmpty { + accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" + } else { + accountTitle = label + } + return CodexCLIProxyAuthCompactGridView.Entry( + id: snapshot.id, + accountTitle: accountTitle, + primaryPercent: primary, + secondaryPercent: secondary, + hasError: snapshot.error != nil) + } + } + + private func percent(for window: RateWindow?) -> Double? { + guard let window else { return nil } + if self.settings.usageBarsShowUsed { + return max(0, min(100, window.usedPercent)) + } + return max(0, min(100, window.remainingPercent)) + } + + 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.selectedMenuProvider ?? self.resolvedMenuProvider() + } + if let provider = self.menuProviders[ObjectIdentifier(menu)] { + return provider + } + if menu === self.fallbackMenu { + return nil + } + return self.store.enabledProviders().first ?? .codex + } + + private func scheduleOpenMenuRefresh(for menu: NSMenu) { + // Kick off a background 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 } + let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() + let isStale = provider.map { self.store.isStale(provider: $0) } ?? self.store.isStale + let hasSnapshot = provider.map { self.store.snapshot(for: $0) != nil } ?? true + guard isStale || !hasSnapshot else { return } + self.refreshStore(forceTokenUsage: false) + } + } + + 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.enabledProviders(), 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) -> 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) + // 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 + let weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + 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 + 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) { + self.highlightState = highlightState + super.init(rootView: rootView) + } + + required init(rootView: Content) { + self.highlightState = MenuCardHighlightState() + super.init(rootView: rootView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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 struct CodexCLIProxyAuthCompactGridView: View { + struct Entry: Identifiable { + let id: UUID + let accountTitle: String + let primaryPercent: Double? + let secondaryPercent: Double? + let hasError: Bool + } + + let entries: [Entry] + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var columns: [GridItem] { + [ + GridItem(.flexible(minimum: 120), spacing: 8), + GridItem(.flexible(minimum: 120), spacing: 8), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let titleFormat = L10n.tr( + "menu.codex.cliproxy.auth_grid.title", + fallback: "Codex auth entries (%d)") + Text(String(format: titleFormat, locale: .current, self.entries.count)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + + LazyVGrid(columns: self.columns, spacing: 8) { + ForEach(self.entries) { entry in + AccountCell( + entry: entry, + isHighlighted: self.isHighlighted) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + private struct AccountCell: View { + let entry: CodexCLIProxyAuthCompactGridView.Entry + let isHighlighted: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.entry.accountTitle) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + + HStack(spacing: 16) { + RingBadge( + percent: self.entry.primaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemTeal), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + RingBadge( + percent: self.entry.secondaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemIndigo), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) + } + } + + private struct RingBadge: View { + let percent: Double? + let isError: Bool + let tint: Color + let isHighlighted: Bool + + private var normalizedPercent: Double { + guard let percent else { return 0 } + return max(0, min(100, percent)) + } + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let lineWidth = max(3, diameter * 0.11) + let fontSize = max(10, diameter * 0.32) + + ZStack { + Circle() + .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: self.normalizedPercent / 100) + .stroke( + MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) + + if self.isError { + Image(systemName: "xmark") + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + } else if self.percent == nil { + Text("—") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } else { + Text("\(Int(self.normalizedPercent.rounded()))") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + } + } + .frame(width: diameter, height: diameter) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .aspectRatio(1, contentMode: .fit) + } + } + + private func makeBuyCreditsItem() -> NSMenuItem { + let item = NSMenuItem( + title: L10n.tr("menu.action.buy_credits", fallback: "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: L10n.tr("menu.action.credits_history", fallback: "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: L10n.tr("menu.action.usage_breakdown", fallback: "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.enabledProviders() + 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.enabledProviders().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 input = UsageMenuCardView.Model.Input( + provider: target, + metadata: metadata, + sourceLabel: self.store.sourceLabel(for: target), + 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, + hidePersonalInfo: self.settings.hidePersonalInfo, + now: Date()) + return UsageMenuCardView.Model.make(input) + } + + @objc private func menuCardNoOp(_ sender: NSMenuItem) { + _ = sender + } + + 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 + } +} diff --git a/Package.swift b/Package.swift index 83cbfca65..28401e8f2 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency = let package = Package( name: "CodexBar", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -33,6 +34,9 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index a6a3754b7..ce20b2ce4 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -190,6 +190,10 @@ cat > "$APP/Contents/Info.plist" <CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 LSUIElement + NSAppTransportSecurity + + NSAllowsArbitraryLoads + CFBundleIconFileIcon NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5c0bb2d9b..5d6bb62d0 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,15 +11,19 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: + L10n.tr("menu.card.percent.left", fallback: "left") + case .used: + L10n.tr("menu.card.percent.used", fallback: "used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: + L10n.tr("menu.card.accessibility.usage_remaining", fallback: "Usage remaining") + case .used: + L10n.tr("menu.card.accessibility.usage_used", fallback: "Usage used") } } } @@ -135,7 +139,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("menu.card.cost.title", fallback: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -460,19 +464,22 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + let format = L10n.tr("menu.card.tokens.unit", fallback: "%@ tokens") + return String(format: format, locale: .current, scale) } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(L10n.tr("menu.card.credits.title", fallback: "Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: L10n.tr( + "menu.card.accessibility.credits_remaining", + fallback: "Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -513,7 +520,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text(L10n.tr("menu.card.cost.title", fallback: "Cost")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -576,6 +583,7 @@ extension UsageMenuCardView.Model { struct Input { let provider: UsageProvider let metadata: ProviderMetadata + let sourceLabel: String? let snapshot: UsageSnapshot? let credits: CreditsSnapshot? let creditsError: String? @@ -601,10 +609,16 @@ extension UsageMenuCardView.Model { account: input.account, metadata: input.metadata) let metrics = Self.metrics(input: input) + let isCodexCLIProxy = input.provider == .codex && + (input.sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") ?? false) let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { nil } else { - Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) + Self.creditsLine( + metadata: input.metadata, + credits: input.credits, + error: input.creditsError, + showUnavailableHint: !isCodexCLIProxy) } let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { nil @@ -844,12 +858,14 @@ extension UsageMenuCardView.Model { private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, - error: String?) -> String? + error: String?, + showUnavailableHint: Bool = true) -> String? { guard metadata.supportsCredits else { return nil } if let credits { return UsageFormatter.creditsString(from: credits.remaining) } + guard showUnavailableHint else { return nil } if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -876,9 +892,11 @@ extension UsageMenuCardView.Model { let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLine: String = { if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + let format = L10n.tr("menu.card.cost.today_with_tokens", fallback: "Today: %@ · %@ tokens") + return String(format: format, locale: .current, sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + let format = L10n.tr("menu.card.cost.today", fallback: "Today: %@") + return String(format: format, locale: .current, sessionCost) }() let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" @@ -887,9 +905,13 @@ extension UsageMenuCardView.Model { let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let monthLine: String = { if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + let format = L10n.tr( + "menu.card.cost.last_30_days_with_tokens", + fallback: "Last 30 days: %@ · %@ tokens") + return String(format: format, locale: .current, monthCost, monthTokens) } - return "Last 30 days: \(monthCost)" + let format = L10n.tr("menu.card.cost.last_30_days", fallback: "Last 30 days: %@") + return String(format: format, locale: .current, monthCost) }() let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( @@ -912,17 +934,17 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = L10n.tr("menu.card.provider_cost.quota_usage", fallback: "Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = L10n.tr("menu.card.provider_cost.extra_usage", fallback: "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" + let periodLabel = cost.period ?? L10n.tr("menu.card.provider_cost.this_month", fallback: "This month") return ProviderCostSection( title: title, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 36a9c861b..0848fd050 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -245,11 +245,15 @@ struct MenuDescriptor { settings: store.settings, account: account) } + let hideSwitchAccountAction = Self.shouldHideSwitchAccountAction( + provider: targetProvider, + store: store) // Show "Add Account" if no account, "Switch Account" if logged in if let targetProvider, let implementation = ProviderCatalog.implementation(for: targetProvider), - implementation.supportsLoginFlow + implementation.supportsLoginFlow, + !hideSwitchAccountAction { if let loginContext, let override = implementation.loginMenuAction(context: loginContext) @@ -258,7 +262,9 @@ struct MenuDescriptor { } 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..." + let accountLabel = hasAccount + ? L10n.tr("menu.action.switch_account", fallback: "Switch Account...") + : L10n.tr("menu.action.add_account", fallback: "Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -274,10 +280,10 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L10n.tr("menu.action.usage_dashboard", fallback: "Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L10n.tr("menu.action.status_page", fallback: "Status Page"), .statusPage)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -290,12 +296,14 @@ struct MenuDescriptor { private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { - entries.append(.action("Update ready, restart now?", .installUpdate)) + entries.append(.action( + L10n.tr("menu.action.install_update", fallback: "Update ready, restart now?"), + .installUpdate)) } entries.append(contentsOf: [ - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L10n.tr("menu.action.settings", fallback: "Settings..."), .settings), + .action(L10n.tr("menu.action.about", fallback: "About CodexBar"), .about), + .action(L10n.tr("menu.action.quit", fallback: "Quit"), .quit), ]) return Section(entries: entries) } @@ -315,6 +323,14 @@ struct MenuDescriptor { return label } + private static func shouldHideSwitchAccountAction(provider: UsageProvider?, store: UsageStore) -> Bool { + guard provider == .codex else { return false } + let codexSettings = store.settings.codexSettingsSnapshot(tokenOverride: nil) + return CodexCLIProxySettings.resolve( + providerSettings: codexSettings, + environment: ProcessInfo.processInfo.environment) != nil + } + 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) } diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift index 99ec8eef6..89acd2399 100644 --- a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift +++ b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift @@ -420,7 +420,7 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - window.title = "Buy Credits" + window.title = L10n.tr("window.buy_credits.title", fallback: "Buy Credits") window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] window.contentView = container diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..69b5042a2 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -11,20 +11,112 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(L10n.tr("settings.general.system.section", fallback: "System")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: L10n.tr("settings.general.system.start_at_login.title", fallback: "Start at Login"), + subtitle: L10n.tr( + "settings.general.system.start_at_login.subtitle", + fallback: "Automatically opens CodexBar when you start your Mac."), binding: self.$settings.launchAtLogin) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.language.title", fallback: "Language")) + .font(.body) + Text(L10n.tr( + "settings.general.language.subtitle", + fallback: "Choose app display language.")) + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("", selection: self.$settings.appLanguage) { + ForEach(AppLanguageOption.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + Text(L10n.tr( + "settings.general.language.restart_hint", + fallback: "Language changes apply after restart.")) + .font(.footnote) + .foregroundStyle(.secondary) + HStack { + Spacer() + Button(L10n.tr("settings.general.language.apply_restart", fallback: "Apply & Restart")) { + self.restartApp() + } + .buttonStyle(.bordered) + } + } + } + + Divider() + + SettingsSection(contentSpacing: 12) { + Text(L10n.tr("settings.general.cliproxy.section", fallback: "CLIProxyAPI")) + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.url.title", fallback: "Base URL")) + .font(.body) + Text(L10n.tr( + "settings.general.cliproxy.url.subtitle", + fallback: "Global default for providers using API source (for example Codex).")) + .font(.footnote) + .foregroundStyle(.tertiary) + TextField( + L10n.tr( + "settings.general.cliproxy.url.placeholder", + fallback: "http://127.0.0.1:8317"), + text: self.$settings.cliProxyGlobalBaseURL) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.key.title", fallback: "Management Key")) + .font(.body) + SecureField( + L10n.tr( + "settings.general.cliproxy.key.placeholder", + fallback: "Paste management key…"), + text: self.$settings.cliProxyGlobalManagementKey) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("settings.general.cliproxy.auth_index.title", fallback: "auth_index (optional)")) + .font(.body) + Text(L10n.tr( + "settings.general.cliproxy.auth_index.subtitle", + fallback: "Optional. Set a specific auth file; leave empty to aggregate all Codex auth entries.")) + .font(.footnote) + .foregroundStyle(.tertiary) + TextField( + L10n.tr( + "settings.general.cliproxy.auth_index.placeholder", + fallback: "Leave empty to load all available Codex auth entries"), + text: self.$settings.cliProxyGlobalAuthIndex) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + } } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(L10n.tr("settings.general.usage.section", fallback: "Usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +124,22 @@ struct GeneralPane: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { - Text("Show cost summary") + Text(L10n.tr("settings.general.usage.cost_summary.title", fallback: "Show cost summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(L10n.tr( + "settings.general.usage.cost_summary.subtitle", + fallback: "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") + Text(L10n.tr( + "settings.general.usage.cost_summary.refresh_hint", + fallback: "Auto-refresh: hourly · Timeout: 10m")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,21 +153,26 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(L10n.tr("settings.general.automation.section", fallback: "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") + Text(L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(L10n.tr( + "settings.general.automation.refresh_cadence.subtitle", + fallback: "How often CodexBar polls providers in the background.")) .font(.footnote) .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker( + L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence"), + selection: self.$settings.refreshFrequency) + { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -81,20 +182,26 @@ struct GeneralPane: View { .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { - Text("Auto-refresh is off; use the menu's Refresh command.") + Text(L10n.tr( + "settings.general.automation.refresh_cadence.manual_hint", + fallback: "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.", + title: L10n.tr("settings.general.automation.check_status.title", fallback: "Check provider status"), + subtitle: L10n.tr( + "settings.general.automation.check_status.subtitle", + fallback: "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.", + title: L10n.tr( + "settings.general.automation.session_quota.title", + fallback: "Session quota notifications"), + subtitle: L10n.tr( + "settings.general.automation.session_quota.subtitle", + fallback: "Notifies when the 5-hour session quota hits 0% and when it becomes available again."), binding: self.$settings.sessionQuotaNotificationsEnabled) } @@ -103,7 +210,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(L10n.tr("settings.general.quit", fallback: "Quit CodexBar")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +226,8 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + let format = L10n.tr("settings.general.usage.cost_status.unsupported", fallback: "%@: unsupported") + return Text(String(format: format, locale: .current, name)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,14 +241,16 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + let format = L10n.tr("settings.general.usage.cost_status.fetching", fallback: "%@: fetching…%@") + return Text(String(format: format, locale: .current, name, 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)") + let format = L10n.tr("settings.general.usage.cost_status.snapshot", fallback: "%@: %@ · 30d %@") + return Text(String(format: format, locale: .current, name, updated, cost)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -154,12 +264,25 @@ struct GeneralPane: View { let rel = RelativeDateTimeFormatter() rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + let format = L10n.tr("settings.general.usage.cost_status.last_attempt", fallback: "%@: last attempt %@") + return Text(String(format: format, locale: .current, name, when)) .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + let format = L10n.tr("settings.general.usage.cost_status.no_data", fallback: "%@: no data yet") + return Text(String(format: format, locale: .current, name)) .font(.footnote) .foregroundStyle(.tertiary) } + + private func restartApp() { + let bundleURL = Bundle.main.bundleURL + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: bundleURL, configuration: configuration) { _, _ in + Task { @MainActor in + NSApp.terminate(nil) + } + } + } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 379487ace..378cbda4d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -256,24 +256,41 @@ struct ProvidersPane: View { if provider == .zai { return nil } let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) + let primaryFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.primary_with_label", + fallback: "Primary (%@)") + let secondaryFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.secondary_with_label", + fallback: "Secondary (%@)") var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.automatic.rawValue, + title: MenuBarMetricPreference.automatic.label), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: String(format: primaryFormat, locale: .current, metadata.sessionLabel)), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: String(format: secondaryFormat, locale: .current, metadata.weeklyLabel)), ] if supportsAverage { + let averageFormat = L10n.tr( + "settings.providers.menu_bar_metric.option.average_with_labels", + fallback: "Average (%@ + %@)") options.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: String( + format: averageFormat, + locale: .current, + metadata.sessionLabel, + metadata.weeklyLabel))) } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: L10n.tr("settings.providers.menu_bar_metric.title", fallback: "Menu bar metric"), + subtitle: L10n.tr( + "settings.providers.menu_bar_metric.subtitle", + fallback: "Choose which window drives the menu bar percent."), binding: Binding( get: { self.settings.menuBarMetricPreference(for: provider).rawValue }, set: { rawValue in @@ -320,6 +337,7 @@ struct ProvidersPane: View { let input = UsageMenuCardView.Model.Input( provider: provider, metadata: metadata, + sourceLabel: self.store.sourceLabel(for: provider), snapshot: snapshot, credits: credits, creditsError: creditsError, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 35baa270d..2bb6f3fcb 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -11,7 +11,8 @@ struct CodexProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { ProviderPresentation { context in - context.store.version(for: context.provider) ?? "not detected" + context.store.version(for: context.provider) + ?? L10n.tr("provider.codex.version.not_detected", fallback: "not detected") } } @@ -20,6 +21,9 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex } @MainActor @@ -49,6 +53,7 @@ struct CodexProviderImplementation: ProviderImplementation { switch context.settings.codexUsageDataSource { case .auto: .auto case .oauth: .oauth + case .api: .api case .cli: .cli } } @@ -73,8 +78,10 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", - title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", + title: L10n.tr("provider.codex.toggle.openai_web_extras.title", fallback: "OpenAI web extras"), + subtitle: L10n.tr( + "provider.codex.toggle.openai_web_extras.subtitle", + fallback: "Show usage breakdown, credits history, and code review via chatgpt.com."), binding: extrasBinding, statusText: nil, actions: [], @@ -109,16 +116,24 @@ struct CodexProviderImplementation: ProviderImplementation { 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.") + auto: L10n.tr( + "provider.codex.picker.cookie_source.auto", + fallback: "Automatic imports browser cookies for dashboard extras."), + manual: L10n.tr( + "provider.codex.picker.cookie_source.manual", + fallback: "Paste a Cookie header from a chatgpt.com request."), + off: L10n.tr( + "provider.codex.picker.cookie_source.off", + fallback: "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.", + title: L10n.tr("provider.codex.picker.usage_source.title", fallback: "Usage source"), + subtitle: L10n.tr( + "provider.codex.picker.usage_source.subtitle", + fallback: "Auto falls back to the next source if the preferred one fails."), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -130,8 +145,10 @@ struct CodexProviderImplementation: ProviderImplementation { }), ProviderSettingsPickerDescriptor( id: "codex-cookie-source", - title: "OpenAI cookies", - subtitle: "Automatic imports browser cookies for dashboard extras.", + title: L10n.tr("provider.codex.picker.cookie_source.title", fallback: "OpenAI cookies"), + subtitle: L10n.tr( + "provider.codex.picker.cookie_source.subtitle", + fallback: "Automatic imports browser cookies for dashboard extras."), dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -140,7 +157,8 @@ struct CodexProviderImplementation: ProviderImplementation { trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + let format = L10n.tr("provider.codex.cookie.cached", fallback: "Cached: %@ • %@") + return String(format: format, locale: .current, entry.sourceLabel, when) }), ] } @@ -153,7 +171,7 @@ struct CodexProviderImplementation: ProviderImplementation { title: "", subtitle: "", kind: .secure, - placeholder: "Cookie: …", + placeholder: L10n.tr("provider.codex.field.cookie_header.placeholder", fallback: "Cookie: …"), binding: context.stringBinding(\.codexCookieHeader), actions: [], isVisible: { @@ -168,13 +186,19 @@ struct CodexProviderImplementation: ProviderImplementation { guard context.settings.showOptionalCreditsAndExtraUsage, context.metadata.supportsCredits else { return } + let isCLIProxySource = context.store.sourceLabel(for: .codex) + .localizedCaseInsensitiveContains("cliproxy-api") if let credits = context.store.credits { - entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) + let creditsValue = UsageFormatter.creditsString(from: credits.remaining) + let creditsFormat = L10n.tr("provider.codex.menu.credits", fallback: "Credits: %@") + entries.append(.text(String(format: creditsFormat, locale: .current, creditsValue), .primary)) if let latest = credits.events.first { - entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) + let spendValue = UsageFormatter.creditEventSummary(latest) + let spendFormat = L10n.tr("provider.codex.menu.last_spend", fallback: "Last spend: %@") + entries.append(.text(String(format: spendFormat, locale: .current, spendValue), .secondary)) } - } else { + } else if !isCLIProxySource { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint entries.append(.text(hint, .secondary)) } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 335dbf411..10e49694a 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -11,6 +11,7 @@ extension SettingsStore { let source: ProviderSourceMode? = switch newValue { case .auto: .auto case .oauth: .oauth + case .api: .api case .cli: .cli } self.updateProviderConfig(provider: .codex) { entry in @@ -44,22 +45,72 @@ extension SettingsStore { } } + var codexCLIProxyBaseURL: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIBaseURL ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiBaseURL = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .codex, field: "apiBaseURL", value: newValue) + } + } + + var codexCLIProxyManagementKey: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .codex, field: "apiKey", value: newValue) + } + } + + var codexCLIProxyAuthIndex: String { + get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIAuthIndex ?? "" } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.apiAuthIndex = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .codex, field: "apiAuthIndex", value: newValue) + } + } + func ensureCodexCookieLoaded() {} } extension SettingsStore { func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings { - ProviderSettingsSnapshot.CodexProviderSettings( + let resolvedBaseURL: String = { + let providerValue = self.codexCLIProxyBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return self.cliProxyGlobalBaseURL + }() + let resolvedManagementKey: String = { + let providerValue = self.codexCLIProxyManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return self.cliProxyGlobalManagementKey + }() + let resolvedAuthIndex: String = { + let providerValue = self.codexCLIProxyAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if !providerValue.isEmpty { return providerValue } + return self.cliProxyGlobalAuthIndex + }() + return ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride)) + manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), + cliProxyBaseURL: resolvedBaseURL, + cliProxyManagementKey: resolvedManagementKey, + cliProxyAuthIndex: resolvedAuthIndex) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { guard let source else { return .auto } switch source { - case .auto, .web, .api: + case .auto, .web: return .auto + case .api: + return .api case .cli: return .cli case .oauth: diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0e06b99fc..6e98a7aec 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -274,6 +274,41 @@ extension SettingsStore { } } + var appLanguage: AppLanguageOption { + get { AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) ?? .system } + set { + self.defaultsState.appLanguageRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "appLanguageCode") + } + } + + var cliProxyGlobalBaseURL: String { + get { self.defaultsState.cliProxyGlobalBaseURL } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalBaseURL = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalBaseURL") + } + } + + var cliProxyGlobalManagementKey: String { + get { self.defaultsState.cliProxyGlobalManagementKey } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalManagementKey = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalManagementKey") + } + } + + var cliProxyGlobalAuthIndex: String { + get { self.defaultsState.cliProxyGlobalAuthIndex } + set { + let normalized = self.normalizedConfigValue(newValue) ?? "" + self.defaultsState.cliProxyGlobalAuthIndex = normalized + self.userDefaults.set(normalized, forKey: "cliProxyGlobalAuthIndex") + } + } + var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index e140de0ac..f48e9c7f8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -28,12 +28,18 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { 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" + case .manual: + L10n.tr("settings.general.refresh_frequency.manual", fallback: "Manual") + case .oneMinute: + L10n.tr("settings.general.refresh_frequency.one_minute", fallback: "1 min") + case .twoMinutes: + L10n.tr("settings.general.refresh_frequency.two_minutes", fallback: "2 min") + case .fiveMinutes: + L10n.tr("settings.general.refresh_frequency.five_minutes", fallback: "5 min") + case .fifteenMinutes: + L10n.tr("settings.general.refresh_frequency.fifteen_minutes", fallback: "15 min") + case .thirtyMinutes: + L10n.tr("settings.general.refresh_frequency.thirty_minutes", fallback: "30 min") } } } @@ -50,10 +56,35 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { var label: String { switch self { - case .automatic: "Automatic" - case .primary: "Primary" - case .secondary: "Secondary" - case .average: "Average" + case .automatic: + L10n.tr("settings.providers.menu_bar_metric.option.automatic", fallback: "Automatic") + case .primary: + L10n.tr("settings.providers.menu_bar_metric.option.primary", fallback: "Primary") + case .secondary: + L10n.tr("settings.providers.menu_bar_metric.option.secondary", fallback: "Secondary") + case .average: + L10n.tr("settings.providers.menu_bar_metric.option.average", fallback: "Average") + } + } +} + +enum AppLanguageOption: String, CaseIterable, Identifiable { + case system + case english = "en" + case simplifiedChinese = "zh-Hans" + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .system: + return L10n.tr("settings.general.language.option.system", fallback: "System") + case .english: + return L10n.tr("settings.general.language.option.english", fallback: "English") + case .simplifiedChinese: + return L10n.tr("settings.general.language.option.zh_hans", fallback: "简体中文") } } } @@ -215,6 +246,10 @@ extension SettingsStore { let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let appLanguageRaw = userDefaults.string(forKey: "appLanguageCode") ?? AppLanguageOption.system.rawValue + let cliProxyGlobalBaseURL = userDefaults.string(forKey: "cliProxyGlobalBaseURL") ?? "" + let cliProxyGlobalManagementKey = userDefaults.string(forKey: "cliProxyGlobalManagementKey") ?? "" + let cliProxyGlobalAuthIndex = userDefaults.string(forKey: "cliProxyGlobalAuthIndex") ?? "" return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -244,7 +279,11 @@ extension SettingsStore { mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + appLanguageRaw: appLanguageRaw, + cliProxyGlobalBaseURL: cliProxyGlobalBaseURL, + cliProxyGlobalManagementKey: cliProxyGlobalManagementKey, + cliProxyGlobalAuthIndex: cliProxyGlobalAuthIndex) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9d8e833ba..632b01417 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -29,4 +29,8 @@ struct SettingsDefaultsState: Sendable { var switcherShowsIcons: Bool var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var appLanguageRaw: String + var cliProxyGlobalBaseURL: String + var cliProxyGlobalManagementKey: String + var cliProxyGlobalAuthIndex: String } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index a86e51444..4e6719902 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -32,7 +32,9 @@ extension StatusItemController { let meta = self.store.metadata(for: provider) // For Claude, route subscription users to claude.ai/settings/usage instead of console billing - let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { + let urlString: String? = if provider == .codex, let cliProxyDashboardURL = self.codexCLIProxyUsageDashboardURL() { + cliProxyDashboardURL.absoluteString + } else if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL } else { meta.dashboardURL @@ -70,6 +72,21 @@ extension StatusItemController { return url.absoluteString } + private func codexCLIProxyUsageDashboardURL() -> URL? { + let providerSettings = self.settings.codexSettingsSnapshot(tokenOverride: nil) + guard let cliProxySettings = CodexCLIProxySettings.resolve( + providerSettings: providerSettings, + environment: ProcessInfo.processInfo.environment) + else { + return nil + } + + let dashboardURL = cliProxySettings.baseURL.appendingPathComponent("management.html", isDirectory: false) + guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else { return dashboardURL } + components.fragment = "/usage" + return components.url ?? dashboardURL + } + @objc func openStatusPage() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2fb70778e..ac2a7cf29 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -187,7 +187,6 @@ extension StatusItemController { 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) @@ -233,15 +232,6 @@ extension StatusItemController { 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 - } - self.setButtonTitle(nil, for: button) if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) @@ -267,16 +257,6 @@ extension StatusItemController { // 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 - } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2167b2914..41f5026f5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -279,6 +279,42 @@ extension StatusItemController { private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots + let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( + provider: context.currentProvider, + display: tokenAccountDisplay) + if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: aggregateModel, width: context.menuWidth), + id: "menuCard-aggregate", + width: context.menuWidth)) + if !accountSnapshots.isEmpty { + menu.addItem(.separator()) + } + } + if shouldShowAggregateCard { + let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) + if !entries.isEmpty { + let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) + menu.addItem(self.makeMenuCardItem( + compactView, + id: "menuCard-auth-grid", + width: context.menuWidth)) + } + if let inlineCostHistoryItem = self.makeCostHistoryInlineItem( + provider: context.currentProvider, + width: context.menuWidth) + { + if !entries.isEmpty { + menu.addItem(.separator()) + } + menu.addItem(inlineCostHistoryItem) + menu.addItem(.separator()) + } else if !entries.isEmpty { + menu.addItem(.separator()) + } + return false + } + let cards = accountSnapshots.isEmpty ? [] : accountSnapshots.compactMap { accountSnapshot in @@ -287,7 +323,8 @@ extension StatusItemController { snapshotOverride: accountSnapshot.snapshot, errorOverride: accountSnapshot.error) } - if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + + if cards.isEmpty, !shouldShowAggregateCard, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", @@ -484,6 +521,20 @@ extension StatusItemController { } private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { + if provider == .codex, + let snapshots = self.store.accountSnapshots[provider], + snapshots.count > 1, + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + { + return TokenAccountMenuDisplay( + provider: provider, + accounts: snapshots.map(\.account), + snapshots: snapshots, + activeIndex: 0, + showAll: true, + showSwitcher: false) + } + guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } @@ -499,6 +550,43 @@ extension StatusItemController { showSwitcher: !showAll) } + private func isCodexCLIProxyMultiAuthDisplay( + provider: UsageProvider, + display: TokenAccountMenuDisplay) -> Bool + { + provider == .codex && + display.showAll && + self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + } + + private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { + snapshots.map { snapshot in + let primary = self.percent(for: snapshot.snapshot?.primary) + let secondary = self.percent(for: snapshot.snapshot?.secondary) + let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let accountTitle: String + if label.isEmpty { + accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" + } else { + accountTitle = label + } + return CodexCLIProxyAuthCompactGridView.Entry( + id: snapshot.id, + accountTitle: accountTitle, + primaryPercent: primary, + secondaryPercent: secondary, + hasError: snapshot.error != nil) + } + } + + private func percent(for window: RateWindow?) -> Double? { + guard let window else { return nil } + if self.settings.usageBarsShowUsed { + return max(0, min(100, window.usedPercent)) + } + return max(0, min(100, window.remainingPercent)) + } + private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion @@ -731,12 +819,25 @@ extension StatusItemController { 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)) + width: width)) + + if let inlineCostHistoryItem = self.makeCostHistoryInlineItem(provider: provider, width: width) { + menu.addItem(.separator()) + menu.addItem(inlineCostHistoryItem) + } else if webItems.hasCostHistory { + let costSubmenu = self.makeCostHistorySubmenu(provider: provider) + if costSubmenu != nil { + // Fallback for non-rendering mode: still expose chart through submenu. + if let lastItem = menu.items.last { + lastItem.submenu = costSubmenu + lastItem.target = self + lastItem.action = #selector(self.menuCardNoOp(_:)) + } + } + } } } @@ -904,8 +1005,138 @@ extension StatusItemController { } } + private struct CodexCLIProxyAuthCompactGridView: View { + struct Entry: Identifiable { + let id: UUID + let accountTitle: String + let primaryPercent: Double? + let secondaryPercent: Double? + let hasError: Bool + } + + let entries: [Entry] + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var columns: [GridItem] { + [ + GridItem(.flexible(minimum: 120), spacing: 8), + GridItem(.flexible(minimum: 120), spacing: 8), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let titleFormat = L10n.tr( + "menu.codex.cliproxy.auth_grid.title", + fallback: "Codex auth entries (%d)") + Text(String(format: titleFormat, locale: .current, self.entries.count)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + + LazyVGrid(columns: self.columns, spacing: 8) { + ForEach(self.entries) { entry in + AccountCell( + entry: entry, + isHighlighted: self.isHighlighted) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + private struct AccountCell: View { + let entry: CodexCLIProxyAuthCompactGridView.Entry + let isHighlighted: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.entry.accountTitle) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + + HStack(spacing: 16) { + RingBadge( + percent: self.entry.primaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemTeal), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + RingBadge( + percent: self.entry.secondaryPercent, + isError: self.entry.hasError, + tint: Color(nsColor: NSColor.systemIndigo), + isHighlighted: self.isHighlighted) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) + } + } + + private struct RingBadge: View { + let percent: Double? + let isError: Bool + let tint: Color + let isHighlighted: Bool + + private var normalizedPercent: Double { + guard let percent else { return 0 } + return max(0, min(100, percent)) + } + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let lineWidth = max(3, diameter * 0.11) + let fontSize = max(10, diameter * 0.32) + + ZStack { + Circle() + .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: self.normalizedPercent / 100) + .stroke( + MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) + + if self.isError { + Image(systemName: "xmark") + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + } else if self.percent == nil { + Text("—") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } else { + Text("\(Int(self.normalizedPercent.rounded()))") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + } + } + .frame(width: diameter, height: diameter) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .aspectRatio(1, contentMode: .fit) + } + } + private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: L10n.tr("menu.action.buy_credits", fallback: "Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -918,7 +1149,7 @@ extension StatusItemController { @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: "") + let item = NSMenuItem(title: L10n.tr("menu.action.credits_history", fallback: "Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -928,7 +1159,7 @@ extension StatusItemController { @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: "") + let item = NSMenuItem(title: L10n.tr("menu.action.usage_breakdown", fallback: "Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1096,6 +1327,23 @@ extension StatusItemController { return submenu } + private func makeCostHistoryInlineItem(provider: UsageProvider, width: CGFloat) -> NSMenuItem? { + guard Self.menuCardRenderingEnabled else { return nil } + guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } + guard !tokenSnapshot.daily.isEmpty else { return nil } + + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + return self.makeMenuCardItem( + chartView, + id: "menuCardCostHistoryInline", + width: width) + } + private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ "usageBreakdownChart", @@ -1173,6 +1421,7 @@ extension StatusItemController { let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, + sourceLabel: self.store.sourceLabel(for: target), snapshot: snapshot, credits: credits, creditsError: creditsError, diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0963b8a63..2db927ba7 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation extension UsageStore { + private enum CodexCLIProxyMultiAuthRefreshState { + case notHandled + case success + case failure(Error) + } + /// Force refresh Augment session (called from UI button) func forceRefreshAugmentSession() async { await self.performRuntimeAction(.forceSessionRefresh, for: .augment) @@ -42,6 +48,24 @@ extension UsageStore { } } + let codexCLIProxyMultiAuthState = await self.refreshCodexCLIProxyMultiAuthIfNeeded(provider: provider) + switch codexCLIProxyMultiAuthState { + case .notHandled: + break + case .success: + if let runtime = self.providerRuntimes[provider] { + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + runtime.providerDidRefresh(context: context, provider: provider) + } + return + case let .failure(error): + if let runtime = self.providerRuntimes[provider] { + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + runtime.providerDidFail(context: context, provider: provider, error: error) + } + return + } + let outcome = await spec.fetch() if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() @@ -70,6 +94,10 @@ extension UsageStore { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) self.snapshots[provider] = scoped self.lastSourceLabels[provider] = result.sourceLabel + if provider == .codex { + self.credits = result.credits + self.lastCreditsError = nil + } self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } @@ -98,4 +126,208 @@ extension UsageStore { } } } + + private func refreshCodexCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CodexCLIProxyMultiAuthRefreshState { + guard provider == .codex else { return .notHandled } + guard self.sourceMode(for: .codex) == .api else { return .notHandled } + + let settingsSnapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) + let env = ProviderRegistry.makeEnvironment( + base: ProcessInfo.processInfo.environment, + provider: .codex, + settings: self.settings, + tokenOverride: nil) + + guard let proxySettings = CodexCLIProxySettings.resolve( + providerSettings: settingsSnapshot.codex, + environment: env) + else { + return .notHandled + } + + guard proxySettings.authIndex == nil else { return .notHandled } + + let client = CodexCLIProxyManagementClient(settings: proxySettings) + let auths: [CodexCLIProxyResolvedAuth] + do { + auths = try await client.listCodexAuths() + } catch { + return .notHandled + } + + guard auths.count > 1 else { return .notHandled } + + var accountSnapshots: [TokenAccountUsageSnapshot] = [] + accountSnapshots.reserveCapacity(auths.count) + + var successfulUsageSnapshots: [UsageSnapshot] = [] + successfulUsageSnapshots.reserveCapacity(auths.count) + + var creditBalances: [Double] = [] + creditBalances.reserveCapacity(auths.count) + + var firstError: Error? + for auth in auths { + let account = self.codexCLIProxyAccount(for: auth) + do { + let usage = try await client.fetchCodexUsage(auth: auth) + let mapped = self.codexUsageSnapshot(from: usage, auth: auth) + let labeled = self.applyAccountLabel(mapped, provider: .codex, account: account) + successfulUsageSnapshots.append(labeled) + if let credits = self.codexCreditsSnapshot(from: usage) { + creditBalances.append(credits.remaining) + } + accountSnapshots.append(TokenAccountUsageSnapshot( + account: account, + snapshot: labeled, + error: nil, + sourceLabel: "cliproxy-api")) + } catch { + if firstError == nil { firstError = error } + accountSnapshots.append(TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: error.localizedDescription, + sourceLabel: "cliproxy-api")) + } + } + + let aggregatedCredits: CreditsSnapshot? = if creditBalances.isEmpty { + nil + } else { + CreditsSnapshot(remaining: creditBalances.reduce(0, +), events: [], updatedAt: Date()) + } + + if let aggregate = self.aggregateCodexCLIProxySnapshot( + successfulUsageSnapshots, + totalAuthCount: auths.count) + { + await MainActor.run { + self.handleSessionQuotaTransition(provider: .codex, snapshot: aggregate) + self.snapshots[.codex] = aggregate + self.accountSnapshots[.codex] = accountSnapshots + self.lastSourceLabels[.codex] = "cliproxy-api" + self.lastFetchAttempts[.codex] = [] + self.errors[.codex] = nil + self.credits = aggregatedCredits + self.lastCreditsError = nil + self.failureGates[.codex]?.recordSuccess() + } + return .success + } + + let resolvedError = firstError ?? CodexCLIProxyError.missingCodexAuth(nil) + await MainActor.run { + self.snapshots.removeValue(forKey: .codex) + self.accountSnapshots[.codex] = accountSnapshots + self.lastSourceLabels[.codex] = "cliproxy-api" + self.lastFetchAttempts[.codex] = [] + self.errors[.codex] = resolvedError.localizedDescription + self.credits = nil + self.lastCreditsError = nil + } + return .failure(resolvedError) + } + + private func codexCLIProxyAccount(for auth: CodexCLIProxyResolvedAuth) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: self.codexCLIProxyAccountLabel(auth), + token: "", + addedAt: 0, + lastUsed: nil) + } + + private func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String { + if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty { + return email + } + return auth.authIndex + } + + private func aggregateCodexCLIProxySnapshot( + _ snapshots: [UsageSnapshot], + totalAuthCount: Int) -> UsageSnapshot? + { + guard !snapshots.isEmpty else { return nil } + + let primary = self.aggregateWindow(snapshots.compactMap(\.primary)) + let secondary = self.aggregateWindow(snapshots.compactMap(\.secondary)) + let tertiary = self.aggregateWindow(snapshots.compactMap(\.tertiary)) + + let loginMethods = Set( + snapshots.compactMap { snapshot in + snapshot.loginMethod(for: .codex)? + .trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { !$0.isEmpty }) + let loginMethod = loginMethods.count == 1 ? loginMethods.first : nil + + let accountLabelFormat = L10n.tr( + "provider.codex.cliproxy.aggregate.account_label", + fallback: "All Codex auth entries (%d)") + let accountLabel = String(format: accountLabelFormat, locale: .current, totalAuthCount) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + updatedAt: Date(), + identity: identity) + } + + private func aggregateWindow(_ windows: [RateWindow]) -> RateWindow? { + guard !windows.isEmpty else { return nil } + let usedPercent = windows.map(\.usedPercent).reduce(0, +) / Double(windows.count) + let windowMinutes = windows.compactMap(\.windowMinutes).max() + let resetsAt = windows.compactMap(\.resetsAt).min() + let resetDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) } + return RateWindow( + usedPercent: usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private func codexUsageSnapshot(from usage: CodexUsageResponse, auth: CodexCLIProxyResolvedAuth) -> UsageSnapshot { + let primary = self.codexRateWindow(from: usage.rateLimit?.primaryWindow) + ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = self.codexRateWindow(from: usage.rateLimit?.secondaryWindow) + let resolvedPlan = usage.planType?.rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackPlan = auth.planType?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan + let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod) + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + .scoped(to: .codex) + } + + private func codexRateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: UsageFormatter.resetDescription(from: resetDate)) + } + + private func codexCreditsSnapshot(from usage: CodexUsageResponse) -> CreditsSnapshot? { + guard let credits = usage.credits, credits.hasCredits, let balance = credits.balance else { return nil } + return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) + } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6b3672ed4..fece1aac4 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -607,6 +607,15 @@ final class UsageStore { private func refreshCreditsIfNeeded() async { guard self.isEnabled(.codex) else { return } + if self.sourceMode(for: .codex) == .api { + await MainActor.run { + self.credits = nil + self.lastCreditsError = nil + self.lastCreditsSnapshot = nil + self.creditsFailureStreak = 0 + } + return + } do { let credits = try await self.codexFetcher.loadLatestCredits( keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index d0ab5dfe6..12d9f0357 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -170,6 +170,16 @@ extension CodexBarCLI { tokenContext: TokenAccountCLIContext, command: UsageCommandContext) async -> UsageCommandOutput { + if provider == .codex, + let output = await Self.fetchCodexCLIProxyUsageOutputsIfNeeded( + provider: provider, + status: status, + tokenContext: tokenContext, + command: command) + { + return output + } + let accounts: [ProviderTokenAccount] do { accounts = try tokenContext.resolvedAccounts(for: provider) @@ -189,7 +199,8 @@ extension CodexBarCLI { account: account, status: status, tokenContext: tokenContext, - command: command) + command: command, + environmentOverride: [:]) output.merge(result) } return output @@ -227,13 +238,17 @@ extension CodexBarCLI { account: ProviderTokenAccount?, status: ProviderStatusPayload?, tokenContext: TokenAccountCLIContext, - command: UsageCommandContext) async -> UsageCommandOutput + command: UsageCommandContext, + environmentOverride: [String: String]) async -> UsageCommandOutput { var output = UsageCommandOutput() - let env = tokenContext.environment( + var env = tokenContext.environment( base: ProcessInfo.processInfo.environment, provider: provider, account: account) + for (key, value) in environmentOverride { + env[key] = value + } let settings = tokenContext.settingsSnapshot(for: provider, account: account) let configSource = tokenContext.preferredSourceMode(for: provider) let baseSource = command.sourceModeOverride ?? configSource @@ -350,6 +365,67 @@ extension CodexBarCLI { return output } + private static func fetchCodexCLIProxyUsageOutputsIfNeeded( + provider: UsageProvider, + status: ProviderStatusPayload?, + tokenContext: TokenAccountCLIContext, + command: UsageCommandContext) async -> UsageCommandOutput? + { + let configSource = tokenContext.preferredSourceMode(for: provider) + let baseSource = command.sourceModeOverride ?? configSource + let sourceMode = tokenContext.effectiveSourceMode(base: baseSource, provider: provider, account: nil) + guard sourceMode == .api else { return nil } + + let baseEnv = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: nil) + let settings = tokenContext.settingsSnapshot(for: provider, account: nil) + guard let proxySettings = CodexCLIProxySettings.resolve( + providerSettings: settings?.codex, + environment: baseEnv) + else { + return nil + } + guard proxySettings.authIndex == nil else { return nil } + + let client = CodexCLIProxyManagementClient(settings: proxySettings) + let auths: [CodexCLIProxyResolvedAuth] + do { + auths = try await client.listCodexAuths() + } catch { + return nil + } + guard auths.count > 1 else { return nil } + + var output = UsageCommandOutput() + for auth in auths { + let label = Self.codexCLIProxyAccountLabel(auth) + let account = ProviderTokenAccount( + id: UUID(), + label: label, + token: "", + addedAt: 0, + lastUsed: nil) + let result = await Self.fetchUsageOutput( + provider: provider, + account: account, + status: status, + tokenContext: tokenContext, + command: command, + environmentOverride: [CodexCLIProxySettings.environmentAuthIndexKey: auth.authIndex]) + output.merge(result) + } + return output + } + + private static func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String { + if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty { + return email + } + return auth.authIndex + } + private static func fetchAntigravityPlanInfoIfNeeded( provider: UsageProvider, command: UsageCommandContext) async -> AntigravityPlanInfoSummary? diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4809cfb06..fb1486441 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -82,11 +82,15 @@ struct TokenAccountCLIContext { switch provider { case .codex: + let codexSource = self.resolveCodexUsageDataSource(config) return self.makeSnapshot( codex: ProviderSettingsSnapshot.CodexProviderSettings( - usageDataSource: .auto, + usageDataSource: codexSource, cookieSource: cookieSource, - manualCookieHeader: cookieHeader)) + manualCookieHeader: cookieHeader, + cliProxyBaseURL: config?.sanitizedAPIBaseURL, + cliProxyManagementKey: config?.sanitizedAPIKey, + cliProxyAuthIndex: config?.sanitizedAPIAuthIndex)) case .claude: let claudeSource: ClaudeUsageDataSource = if provider == .claude, let account, @@ -179,6 +183,20 @@ struct TokenAccountCLIContext { jetbrains: jetbrains) } + private func resolveCodexUsageDataSource(_ config: ProviderConfig?) -> CodexUsageDataSource { + guard let source = config?.source else { return .auto } + switch source { + case .auto, .web: + return .auto + case .api: + return .api + case .cli: + return .cli + case .oauth: + return .oauth + } + } + func environment( base: [String: String], provider: UsageProvider, diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index c4bbbc2cc..a7c3182c9 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -77,6 +77,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var enabled: Bool? public var source: ProviderSourceMode? public var apiKey: String? + public var apiBaseURL: String? + public var apiAuthIndex: String? public var cookieHeader: String? public var cookieSource: ProviderCookieSource? public var region: String? @@ -88,6 +90,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { enabled: Bool? = nil, source: ProviderSourceMode? = nil, apiKey: String? = nil, + apiBaseURL: String? = nil, + apiAuthIndex: String? = nil, cookieHeader: String? = nil, cookieSource: ProviderCookieSource? = nil, region: String? = nil, @@ -98,6 +102,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.enabled = enabled self.source = source self.apiKey = apiKey + self.apiBaseURL = apiBaseURL + self.apiAuthIndex = apiAuthIndex self.cookieHeader = cookieHeader self.cookieSource = cookieSource self.region = region @@ -109,6 +115,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.apiKey) } + public var sanitizedAPIBaseURL: String? { + Self.clean(self.apiBaseURL) + } + + public var sanitizedAPIAuthIndex: String? { + Self.clean(self.apiAuthIndex) + } + public var sanitizedCookieHeader: String? { Self.clean(self.cookieHeader) } diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index d435a28f6..7c9e7a1c7 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -167,6 +167,30 @@ public enum CodexBarConfigValidator { message: "workspaceID is set but only opencode supports workspaceID.")) } + if let apiBaseURL = entry.apiBaseURL, + !apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + provider != .codex + { + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "apiBaseURL", + code: "api_base_url_unused", + message: "apiBaseURL is set but only codex currently uses it.")) + } + + if let apiAuthIndex = entry.apiAuthIndex, + !apiAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + provider != .codex + { + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "apiAuthIndex", + code: "api_auth_index_unused", + message: "apiAuthIndex is set but only codex currently uses it.")) + } + if let tokenAccounts = entry.tokenAccounts, !tokenAccounts.accounts.isEmpty, TokenAccountSupportCatalog.support(for: provider) == nil { diff --git a/Sources/CodexBarCore/Localization.swift b/Sources/CodexBarCore/Localization.swift new file mode 100644 index 000000000..64926fc96 --- /dev/null +++ b/Sources/CodexBarCore/Localization.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum L10n { + private static let appLanguageKey = "appLanguageCode" + + public static func tr(_ key: String, fallback: String) -> String { + let bundle = self.localizedBundleOverride() ?? .module + return NSLocalizedString( + key, + tableName: "Localizable", + bundle: bundle, + value: fallback, + comment: "") + } + + private static func localizedBundleOverride() -> Bundle? { + guard let raw = UserDefaults.standard.string(forKey: Self.appLanguageKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return nil + } + if raw == "system" { return nil } + + let candidates = [ + raw, + raw.lowercased(), + raw.replacingOccurrences(of: "_", with: "-"), + raw.replacingOccurrences(of: "_", with: "-").lowercased(), + ] + + for candidate in candidates { + if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 4a8d9441d..249cc3023 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -12,9 +12,12 @@ public struct OpenAIDashboardFetcher { public var errorDescription: String? { switch self { case .loginRequired: - "OpenAI web access requires login." + return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.") case let .noDashboardData(body): - "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" + let format = L10n.tr( + "error.codex.openai_web.no_data_with_body", + fallback: "OpenAI dashboard data not found. Body sample: %@") + return String(format: format, locale: .current, String(body.prefix(200))) } } } @@ -471,9 +474,12 @@ public struct OpenAIDashboardFetcher { public var errorDescription: String? { switch self { case .loginRequired: - "OpenAI web access requires login." + return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.") case let .noDashboardData(body): - "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" + let format = L10n.tr( + "error.codex.openai_web.no_data_with_body", + fallback: "OpenAI dashboard data not found. Body sample: %@") + return String(format: format, locale: .current, String(body.prefix(200))) } } } @@ -486,7 +492,10 @@ public struct OpenAIDashboardFetcher { debugDumpHTML _: Bool = false, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { - throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") + throw FetchError.noDashboardData( + body: L10n.tr( + "error.codex.openai_web.unsupported_platform", + fallback: "OpenAI web dashboard fetch is only supported on macOS.")) } } #endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift new file mode 100644 index 000000000..8c15433b4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift @@ -0,0 +1,306 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum CodexCLIProxyError: LocalizedError, Sendable { + case invalidBaseURL + case missingManagementKey + case invalidResponse + case managementRequestFailed(Int, String?) + case missingCodexAuth(String?) + case apiCallFailed(Int, String?) + case decodeFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidBaseURL: + return L10n.tr("error.codex.cliproxy.invalid_base_url", fallback: "CLIProxyAPI base URL is invalid.") + case .missingManagementKey: + return L10n.tr( + "error.codex.cliproxy.missing_management_key", + fallback: "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI.") + case .invalidResponse: + return L10n.tr("error.codex.cliproxy.invalid_response", fallback: "CLIProxyAPI returned an invalid response.") + case let .managementRequestFailed(status, message): + if let message, !message.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.management_failed_with_message", + fallback: "CLIProxyAPI management API failed (%d): %@") + return String(format: format, locale: .current, status, message) + } + let format = L10n.tr( + "error.codex.cliproxy.management_failed", + fallback: "CLIProxyAPI management API failed (%d).") + return String(format: format, locale: .current, status) + case let .missingCodexAuth(authIndex): + if let authIndex, !authIndex.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.missing_auth_with_index", + fallback: "CLIProxyAPI did not find Codex auth_index %@.") + return String(format: format, locale: .current, authIndex) + } + return L10n.tr( + "error.codex.cliproxy.missing_auth", + fallback: "CLIProxyAPI has no available Codex auth entry.") + case let .apiCallFailed(status, message): + if let message, !message.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.api_call_failed_with_message", + fallback: "CLIProxyAPI api-call failed (%d): %@") + return String(format: format, locale: .current, status, message) + } + let format = L10n.tr( + "error.codex.cliproxy.api_call_failed", + fallback: "CLIProxyAPI api-call failed (%d).") + return String(format: format, locale: .current, status) + case let .decodeFailed(message): + let format = L10n.tr( + "error.codex.cliproxy.decode_failed", + fallback: "Failed to decode CLIProxyAPI response: %@") + return String(format: format, locale: .current, message) + } + } +} + +public struct CodexCLIProxyResolvedAuth: Sendable { + public let authIndex: String + public let email: String? + public let chatGPTAccountID: String? + public let planType: String? +} + +public struct CodexCLIProxyManagementClient: Sendable { + private let settings: CodexCLIProxySettings + private let session: URLSession + + public init(settings: CodexCLIProxySettings, session: URLSession = .shared) { + self.settings = settings + self.session = session + } + + public func resolveCodexAuth() async throws -> CodexCLIProxyResolvedAuth { + let auths = try await self.listCodexAuths() + + if let preferred = self.settings.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines), + !preferred.isEmpty + { + guard let selected = auths.first(where: { $0.authIndex == preferred }) else { + throw CodexCLIProxyError.missingCodexAuth(preferred) + } + return selected + } + + guard let selected = auths.first else { + throw CodexCLIProxyError.missingCodexAuth(nil) + } + return selected + } + + public func listCodexAuths() async throws -> [CodexCLIProxyResolvedAuth] { + let response = try await self.fetchAuthFiles() + let auths = response.files.filter { file in + let provider = file.provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let type = file.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return provider == "codex" || type == "codex" + } + + let enabledAuths = auths.filter { !($0.disabled ?? false) } + let pool = enabledAuths.isEmpty ? auths : enabledAuths + let mapped = pool.compactMap { auth -> CodexCLIProxyResolvedAuth? in + let resolved = self.mapResolvedAuth(auth) + guard !resolved.authIndex.isEmpty else { return nil } + return resolved + } + return mapped.sorted { left, right in + let l = left.email?.lowercased() ?? left.authIndex.lowercased() + let r = right.email?.lowercased() ?? right.authIndex.lowercased() + return l < r + } + } + + public func fetchCodexUsage(auth: CodexCLIProxyResolvedAuth) async throws -> CodexUsageResponse { + let usageURL = "https://chatgpt.com/backend-api/wham/usage" + var headers = [ + "Authorization": "Bearer $TOKEN$", + "Accept": "application/json", + "User-Agent": "CodexBar", + ] + if let accountID = auth.chatGPTAccountID, !accountID.isEmpty { + headers["ChatGPT-Account-Id"] = accountID + } + + let body = APICallRequest( + authIndex: auth.authIndex, + method: "GET", + url: usageURL, + header: headers, + data: nil) + let callResponse = try await self.post(path: "/api-call", body: body) + + let statusCode = callResponse.statusCode + guard (200...299).contains(statusCode) else { + throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody) + } + + guard let bodyString = callResponse.body else { + throw CodexCLIProxyError.invalidResponse + } + let payload = Data(bodyString.utf8) + do { + return try JSONDecoder().decode(CodexUsageResponse.self, from: payload) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func fetchAuthFiles() async throws -> AuthFilesResponse { + let (data, statusCode) = try await self.get(path: "/auth-files") + guard (200...299).contains(statusCode) else { + let message = String(data: data, encoding: .utf8) + throw CodexCLIProxyError.managementRequestFailed(statusCode, message) + } + do { + return try JSONDecoder().decode(AuthFilesResponse.self, from: data) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func get(path: String) async throws -> (Data, Int) { + let request = try self.makeRequest(path: path, method: "GET", body: nil) + let (data, response) = try await self.session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CodexCLIProxyError.invalidResponse + } + return (data, http.statusCode) + } + + private func post(path: String, body: T) async throws -> APICallResponse { + let requestBody = try JSONEncoder().encode(body) + let request = try self.makeRequest(path: path, method: "POST", body: requestBody) + let (data, response) = try await self.session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CodexCLIProxyError.invalidResponse + } + guard (200...299).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) + throw CodexCLIProxyError.managementRequestFailed(http.statusCode, message) + } + do { + return try JSONDecoder().decode(APICallResponse.self, from: data) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func makeRequest(path: String, method: String, body: Data?) throws -> URLRequest { + guard let base = self.managementURL(path: path) else { + throw CodexCLIProxyError.invalidBaseURL + } + var request = URLRequest(url: base) + request.httpMethod = method + request.timeoutInterval = 30 + request.setValue("Bearer \(self.settings.managementKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func managementURL(path: String) -> URL? { + let trimmedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + let resolvedBaseURL = self.resolvedManagementBaseURL() + return resolvedBaseURL?.appendingPathComponent(trimmedPath) + } + + private func resolvedManagementBaseURL() -> URL? { + let base = self.settings.baseURL + var normalized = base + let path = normalized.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if path.lowercased().hasSuffix("v0/management") { + return normalized + } + normalized.appendPathComponent("v0", isDirectory: false) + normalized.appendPathComponent("management", isDirectory: false) + return normalized + } + + private func mapResolvedAuth(_ auth: AuthFileEntry) -> CodexCLIProxyResolvedAuth { + let authIndex = auth.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return CodexCLIProxyResolvedAuth( + authIndex: authIndex, + email: auth.email, + chatGPTAccountID: auth.idToken?.chatGPTAccountID, + planType: auth.idToken?.planType) + } +} + +private struct AuthFilesResponse: Decodable { + let files: [AuthFileEntry] +} + +private struct AuthFileEntry: Decodable { + let authIndex: String? + let type: String? + let provider: String? + let email: String? + let disabled: Bool? + let idToken: IDTokenClaims? + + enum CodingKeys: String, CodingKey { + case authIndex = "auth_index" + case type + case provider + case email + case disabled + case idToken = "id_token" + } +} + +private struct IDTokenClaims: Decodable { + let chatGPTAccountID: String? + let planType: String? + + enum CodingKeys: String, CodingKey { + case chatGPTAccountID = "chatgpt_account_id" + case planType = "plan_type" + } +} + +private struct APICallRequest: Encodable { + let authIndex: String + let method: String + let url: String + let header: [String: String] + let data: String? + + enum CodingKeys: String, CodingKey { + case authIndex = "auth_index" + case method + case url + case header + case data + } +} + +private struct APICallResponse: Decodable { + let statusCode: Int + let header: [String: [String]]? + let body: String? + + enum CodingKeys: String, CodingKey { + case statusCode = "status_code" + case header + case body + } + + var compactBody: String? { + guard let body else { return nil } + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.count > 320 ? String(trimmed.prefix(320)) + "…" : trimmed + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift new file mode 100644 index 000000000..978c91495 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct CodexCLIProxySettings: Sendable { + public static let defaultBaseURL = "http://127.0.0.1:8317" + public static let environmentBaseURLKey = "CODEX_CLIPROXY_BASE_URL" + public static let environmentManagementKeyKey = "CODEX_CLIPROXY_MANAGEMENT_KEY" + public static let environmentAuthIndexKey = "CODEX_CLIPROXY_AUTH_INDEX" + + public let baseURL: URL + public let managementKey: String + public let authIndex: String? + + public init(baseURL: URL, managementKey: String, authIndex: String?) { + self.baseURL = baseURL + self.managementKey = managementKey + self.authIndex = authIndex + } + + public static func resolve( + providerSettings: ProviderSettingsSnapshot.CodexProviderSettings?, + environment: [String: String]) -> CodexCLIProxySettings? + { + let managementKey = self.cleaned(providerSettings?.cliProxyManagementKey) + ?? self.cleaned(environment[Self.environmentManagementKeyKey]) + guard let managementKey else { return nil } + + let rawBaseURL = self.cleaned(providerSettings?.cliProxyBaseURL) + ?? self.cleaned(environment[Self.environmentBaseURLKey]) + ?? Self.defaultBaseURL + guard let baseURL = self.normalizedURL(rawBaseURL) else { return nil } + + let authIndex = self.cleaned(providerSettings?.cliProxyAuthIndex) + ?? self.cleaned(environment[Self.environmentAuthIndexKey]) + + return CodexCLIProxySettings(baseURL: baseURL, managementKey: managementKey, authIndex: authIndex) + } + + public static func normalizedURL(_ raw: String) -> URL? { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + + if let url = URL(string: value), url.scheme != nil { + return url + } + return URL(string: "http://\(value)") + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..a8410b626 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -10,13 +10,15 @@ public enum CodexProviderDescriptor { metadata: ProviderMetadata( id: .codex, displayName: "Codex", - sessionLabel: "Session", - weeklyLabel: "Weekly", + sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"), + weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"), opusLabel: nil, supportsOpus: false, supportsCredits: true, - creditsHint: "Credits unavailable; keep Codex running to refresh.", - toggleTitle: "Show Codex usage", + creditsHint: L10n.tr( + "provider.codex.metadata.credits_hint", + fallback: "Credits unavailable; keep Codex running to refresh."), + toggleTitle: L10n.tr("provider.codex.metadata.toggle_title", fallback: "Show Codex usage"), cliName: "codex", defaultEnabled: true, isPrimaryProvider: true, @@ -32,7 +34,7 @@ public enum CodexProviderDescriptor { supportsTokenCost: true, noDataMessage: self.noDataMessage), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web, .cli, .oauth], + sourceModes: [.auto, .web, .cli, .oauth, .api], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "codex", @@ -42,6 +44,7 @@ public enum CodexProviderDescriptor { private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { let cli = CodexCLIUsageStrategy() let oauth = CodexOAuthFetchStrategy() + let api = CodexCLIProxyFetchStrategy() let web = CodexWebDashboardStrategy() switch context.runtime { @@ -49,27 +52,27 @@ public enum CodexProviderDescriptor { switch context.sourceMode { case .oauth: return [oauth] + case .api: + return [api] case .web: return [web] case .cli: return [cli] - case .api: - return [] case .auto: - return [web, cli] + return [api, web, cli] } case .app: switch context.sourceMode { case .oauth: return [oauth] + case .api: + return [api] case .cli: return [cli] case .web: return [web] - case .api: - return [] case .auto: - return [oauth, cli] + return [oauth, api, cli] } } } @@ -84,7 +87,10 @@ public enum CodexProviderDescriptor { } ?? "\(home)/.codex" let sessions = "\(base)/sessions" let archived = "\(base)/archived_sessions" - return "No Codex sessions found in \(sessions) or \(archived)." + let format = L10n.tr( + "provider.codex.no_data_message", + fallback: "No Codex sessions found in %@ or %@.") + return String(format: format, locale: .current, sessions, archived) } public static func resolveUsageStrategy( @@ -151,8 +157,11 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { accountId: credentials.accountId) return self.makeResult( - usage: Self.mapUsage(usage, credentials: credentials), - credits: Self.mapCredits(usage.credits), + usage: CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: Self.resolveAccountEmail(from: credentials), + fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials)), + credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits), sourceLabel: "oauth") } @@ -161,40 +170,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { return true } - private static func mapUsage(_ response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> UsageSnapshot { - let primary = Self.makeWindow(response.rateLimit?.primaryWindow) - let secondary = Self.makeWindow(response.rateLimit?.secondaryWindow) - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: Self.resolveAccountEmail(from: credentials), - accountOrganization: nil, - loginMethod: Self.resolvePlan(response: response, credentials: credentials)) - - return UsageSnapshot( - primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: identity) - } - - private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { - guard let credits, let balance = credits.balance else { return nil } - return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) - } - - private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { - guard let window else { return nil } - let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) - let resetDescription = UsageFormatter.resetDescription(from: resetDate) - return RateWindow( - usedPercent: Double(window.usedPercent), - windowMinutes: window.limitWindowSeconds / 60, - resetsAt: resetDate, - resetDescription: resetDescription) - } - private static func resolveAccountEmail(from credentials: CodexOAuthCredentials) -> String? { guard let idToken = credentials.idToken, let payload = UsageFetcher.parseJWT(idToken) @@ -220,11 +195,51 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } } +struct CodexCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "codex.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + if context.sourceMode == .api { return true } + return CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveCodexAuth() + let usage = try await client.fetchCodexUsage(auth: auth) + + return self.makeResult( + usage: CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: auth.email, + fallbackLoginMethod: auth.planType), + credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits), + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} + #if DEBUG extension CodexOAuthFetchStrategy { static func _mapUsageForTesting(_ data: Data, credentials: CodexOAuthCredentials) throws -> UsageSnapshot { let usage = try JSONDecoder().decode(CodexUsageResponse.self, from: data) - return Self.mapUsage(usage, credentials: credentials) + return CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: Self.resolveAccountEmail(from: credentials), + fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials)) } } #endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index ff644cf2b..08442c525 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -34,13 +34,18 @@ public enum CodexStatusProbeError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .codexNotInstalled: - "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart." + return L10n.tr( + "error.codex.status.missing_cli", + fallback: "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.") case .parseFailed: - "Could not parse Codex status; will retry shortly." + return L10n.tr( + "error.codex.status.parse_failed", + fallback: "Could not parse Codex status; will retry shortly.") case .timedOut: - "Codex status probe timed out." + return L10n.tr("error.codex.status.timed_out", fallback: "Codex status probe timed out.") case let .updateRequired(msg): - "Codex CLI update needed: \(msg)" + let format = L10n.tr("error.codex.status.update_required", fallback: "Codex CLI update needed: %@") + return String(format: format, locale: .current, msg) } } } @@ -93,7 +98,9 @@ public struct CodexStatusProbe { } if self.containsUpdatePrompt(clean) { throw CodexStatusProbeError.updateRequired( - "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).") + L10n.tr( + "error.codex.status.update_required_action", + fallback: "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).")) } let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean) // Pull reset info from the same lines that contain the percentages. diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift index b3bb16cd0..910c6b754 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift @@ -3,6 +3,7 @@ import Foundation public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { case auto case oauth + case api case cli public var id: String { @@ -11,9 +12,14 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { - case .auto: "Auto" - case .oauth: "OAuth API" - case .cli: "CLI (RPC/PTY)" + case .auto: + L10n.tr("provider.codex.source.auto", fallback: "Auto") + case .oauth: + L10n.tr("provider.codex.source.oauth", fallback: "OAuth API") + case .api: + L10n.tr("provider.codex.source.api", fallback: "CLIProxyAPI") + case .cli: + L10n.tr("provider.codex.source.cli", fallback: "CLI (RPC/PTY)") } } @@ -23,6 +29,8 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { "auto" case .oauth: "oauth" + case .api: + "cliproxy-api" case .cli: "cli" } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift new file mode 100644 index 000000000..19fbb9352 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift @@ -0,0 +1,46 @@ +import Foundation + +enum CodexUsageSnapshotMapper { + static func usageSnapshot( + from response: CodexUsageResponse, + accountEmail: String?, + fallbackLoginMethod: String?) -> UsageSnapshot + { + let primary = self.makeWindow(response.rateLimit?.primaryWindow) + let secondary = self.makeWindow(response.rateLimit?.secondaryWindow) + + let resolvedPlan = response.planType?.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackPlan = fallbackLoginMethod?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan + let normalizedEmail = accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod) + + return UsageSnapshot( + primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + + static func creditsSnapshot(from credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { + guard let credits, let balance = credits.balance else { return nil } + return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date()) + } + + private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + let resetDescription = UsageFormatter.resetDescription(from: resetDate) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: resetDescription) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index f83cb9fd1..bb40303da 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -38,15 +38,24 @@ public struct ProviderSettingsSnapshot: Sendable { public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let cliProxyBaseURL: String? + public let cliProxyManagementKey: String? + public let cliProxyAuthIndex: String? public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + cliProxyBaseURL: String?, + cliProxyManagementKey: String?, + cliProxyAuthIndex: String?) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.cliProxyBaseURL = cliProxyBaseURL + self.cliProxyManagementKey = cliProxyManagementKey + self.cliProxyAuthIndex = cliProxyAuthIndex } } diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..0036cbd58 --- /dev/null +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -0,0 +1,154 @@ +"provider.codex.source.auto" = "Auto"; +"provider.codex.source.oauth" = "OAuth API"; +"provider.codex.source.api" = "CLIProxyAPI"; +"provider.codex.source.cli" = "CLI (RPC/PTY)"; + +"provider.codex.version.not_detected" = "not detected"; +"provider.codex.cookie.cached" = "Cached: %@ • %@"; +"provider.codex.menu.credits" = "Credits: %@"; +"provider.codex.menu.last_spend" = "Last spend: %@"; + +"provider.codex.metadata.session_label" = "Session"; +"provider.codex.metadata.weekly_label" = "Weekly"; +"provider.codex.metadata.credits_hint" = "Credits unavailable; keep Codex running to refresh."; +"provider.codex.metadata.toggle_title" = "Show Codex usage"; +"provider.codex.no_data_message" = "No Codex sessions found in %@ or %@."; + +"provider.codex.toggle.openai_web_extras.title" = "OpenAI web extras"; +"provider.codex.toggle.openai_web_extras.subtitle" = "Show usage breakdown, credits history, and code review via chatgpt.com."; + +"provider.codex.picker.usage_source.title" = "Usage source"; +"provider.codex.picker.usage_source.subtitle" = "Auto falls back to the next source if the preferred one fails."; + +"provider.codex.picker.cookie_source.title" = "OpenAI cookies"; +"provider.codex.picker.cookie_source.subtitle" = "Automatic imports browser cookies for dashboard extras."; +"provider.codex.picker.cookie_source.auto" = "Automatic imports browser cookies for dashboard extras."; +"provider.codex.picker.cookie_source.manual" = "Paste a Cookie header from a chatgpt.com request."; +"provider.codex.picker.cookie_source.off" = "Disable OpenAI dashboard cookie usage."; + +"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI URL"; +"provider.codex.field.cliproxy_url.subtitle" = "Management API base URL (defaults to http://127.0.0.1:8317)."; +"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317"; +"provider.codex.field.cliproxy_management_key.title" = "CLIProxy management key"; +"provider.codex.field.cliproxy_management_key.subtitle" = "Sent as Authorization Bearer token to /v0/management/* endpoints."; +"provider.codex.field.cliproxy_management_key.placeholder" = "Paste management key…"; +"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index (optional)"; +"provider.codex.field.cliproxy_auth_index.subtitle" = "Leave empty for automatic Codex auth selection."; +"provider.codex.field.cliproxy_auth_index.placeholder" = "Optional auth_index"; +"provider.codex.field.cookie_header.placeholder" = "Cookie: …"; + +"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI base URL is invalid."; +"error.codex.cliproxy.missing_management_key" = "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI."; +"error.codex.cliproxy.invalid_response" = "CLIProxyAPI returned an invalid response."; +"error.codex.cliproxy.management_failed" = "CLIProxyAPI management API failed (%d)."; +"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI management API failed (%d): %@"; +"error.codex.cliproxy.missing_auth" = "CLIProxyAPI has no available Codex auth entry."; +"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI did not find Codex auth_index %@."; +"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call failed (%d)."; +"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call failed (%d): %@"; +"error.codex.cliproxy.decode_failed" = "Failed to decode CLIProxyAPI response: %@"; + +"error.codex.status.missing_cli" = "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart."; +"error.codex.status.parse_failed" = "Could not parse Codex status; will retry shortly."; +"error.codex.status.timed_out" = "Codex status probe timed out."; +"error.codex.status.update_required" = "Codex CLI update needed: %@"; +"error.codex.status.update_required_action" = "Run `bun install -g @openai/codex` to continue (update prompt blocking /status)."; + +"error.codex.rpc.start_failed" = "Codex not running. Try running a Codex command first. (%@)"; +"error.codex.rpc.request_failed" = "Codex connection failed: %@"; +"error.codex.rpc.malformed" = "Codex returned invalid data: %@"; +"error.codex.rpc.chatgpt_auth_required" = "ChatGPT authentication required to read rate limits."; + +"error.codex.openai_web.login_required" = "OpenAI web access requires login."; +"error.codex.openai_web.no_data_with_body" = "OpenAI dashboard data not found. Body sample: %@"; +"error.codex.openai_web.unsupported_platform" = "OpenAI web dashboard fetch is only supported on macOS."; + +"menu.action.switch_account" = "Switch Account..."; +"menu.action.add_account" = "Add Account..."; +"menu.action.usage_dashboard" = "Usage Dashboard"; +"menu.action.status_page" = "Status Page"; +"menu.action.install_update" = "Update ready, restart now?"; +"menu.action.settings" = "Settings..."; +"menu.action.about" = "About CodexBar"; +"menu.action.quit" = "Quit"; +"menu.action.buy_credits" = "Buy Credits..."; +"menu.action.credits_history" = "Credits history"; +"menu.action.usage_breakdown" = "Usage breakdown"; +"menu.codex.cliproxy.auth_grid.title" = "Codex auth entries (%d)"; + +"menu.card.percent.left" = "left"; +"menu.card.percent.used" = "used"; +"menu.card.accessibility.usage_remaining" = "Usage remaining"; +"menu.card.accessibility.usage_used" = "Usage used"; +"menu.card.accessibility.credits_remaining" = "Credits remaining"; +"menu.card.credits.title" = "Credits"; +"menu.card.cost.title" = "Cost"; +"menu.card.tokens.unit" = "%@ tokens"; +"menu.card.cost.today_with_tokens" = "Today: %@ · %@ tokens"; +"menu.card.cost.today" = "Today: %@"; +"menu.card.cost.last_30_days_with_tokens" = "Last 30 days: %@ · %@ tokens"; +"menu.card.cost.last_30_days" = "Last 30 days: %@"; +"menu.card.provider_cost.quota_usage" = "Quota usage"; +"menu.card.provider_cost.extra_usage" = "Extra usage"; +"menu.card.provider_cost.this_month" = "This month"; + +"window.buy_credits.title" = "Buy Credits"; + +"settings.general.language.title" = "Language"; +"settings.general.language.subtitle" = "Choose app display language."; +"settings.general.language.restart_hint" = "Language changes apply after restart."; +"settings.general.language.apply_restart" = "Apply & Restart"; +"settings.general.language.option.system" = "System"; +"settings.general.language.option.english" = "English"; +"settings.general.language.option.zh_hans" = "Simplified Chinese"; + +"settings.general.cliproxy.section" = "CLIProxyAPI"; +"settings.general.cliproxy.url.title" = "Base URL"; +"settings.general.cliproxy.url.subtitle" = "Global default for providers using API source (for example Codex)."; +"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317"; +"settings.general.cliproxy.key.title" = "Management Key"; +"settings.general.cliproxy.key.placeholder" = "Paste management key…"; +"settings.general.cliproxy.auth_index.title" = "auth_index (optional)"; +"settings.general.cliproxy.auth_index.subtitle" = "Optional. Set a specific auth file; leave empty to aggregate all Codex auth entries."; +"settings.general.cliproxy.auth_index.placeholder" = "Leave empty to load all available Codex auth entries"; + +"provider.codex.cliproxy.aggregate.account_label" = "All Codex auth entries (%d)"; + +"settings.general.system.section" = "System"; +"settings.general.system.start_at_login.title" = "Start at Login"; +"settings.general.system.start_at_login.subtitle" = "Automatically opens CodexBar when you start your Mac."; +"settings.general.usage.section" = "Usage"; +"settings.general.usage.cost_summary.title" = "Show cost summary"; +"settings.general.usage.cost_summary.subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"settings.general.usage.cost_summary.refresh_hint" = "Auto-refresh: hourly · Timeout: 10m"; +"settings.general.automation.section" = "Automation"; +"settings.general.automation.refresh_cadence.title" = "Refresh cadence"; +"settings.general.automation.refresh_cadence.subtitle" = "How often CodexBar polls providers in the background."; +"settings.general.automation.refresh_cadence.manual_hint" = "Auto-refresh is off; use the menu's Refresh command."; +"settings.general.automation.check_status.title" = "Check provider status"; +"settings.general.automation.check_status.subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; +"settings.general.automation.session_quota.title" = "Session quota notifications"; +"settings.general.automation.session_quota.subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; +"settings.general.quit" = "Quit CodexBar"; +"settings.general.usage.cost_status.unsupported" = "%@: unsupported"; +"settings.general.usage.cost_status.fetching" = "%@: fetching…%@"; +"settings.general.usage.cost_status.snapshot" = "%@: %@ · 30d %@"; +"settings.general.usage.cost_status.last_attempt" = "%@: last attempt %@"; +"settings.general.usage.cost_status.no_data" = "%@: no data yet"; + +"settings.general.refresh_frequency.manual" = "Manual"; +"settings.general.refresh_frequency.one_minute" = "1 min"; +"settings.general.refresh_frequency.two_minutes" = "2 min"; +"settings.general.refresh_frequency.five_minutes" = "5 min"; +"settings.general.refresh_frequency.fifteen_minutes" = "15 min"; +"settings.general.refresh_frequency.thirty_minutes" = "30 min"; + +"settings.providers.menu_bar_metric.title" = "Menu bar metric"; +"settings.providers.menu_bar_metric.subtitle" = "Choose which window drives the menu bar percent."; +"settings.providers.menu_bar_metric.option.automatic" = "Automatic"; +"settings.providers.menu_bar_metric.option.primary" = "Primary"; +"settings.providers.menu_bar_metric.option.secondary" = "Secondary"; +"settings.providers.menu_bar_metric.option.average" = "Average"; +"settings.providers.menu_bar_metric.option.primary_with_label" = "Primary (%@)"; +"settings.providers.menu_bar_metric.option.secondary_with_label" = "Secondary (%@)"; +"settings.providers.menu_bar_metric.option.average_with_labels" = "Average (%@ + %@)"; diff --git a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..7bffbf1ad --- /dev/null +++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,154 @@ +"provider.codex.source.auto" = "自动"; +"provider.codex.source.oauth" = "OAuth 接口"; +"provider.codex.source.api" = "CLIProxyAPI"; +"provider.codex.source.cli" = "CLI(RPC/PTY)"; + +"provider.codex.version.not_detected" = "未检测到"; +"provider.codex.cookie.cached" = "已缓存:%@ • %@"; +"provider.codex.menu.credits" = "积分:%@"; +"provider.codex.menu.last_spend" = "最近消费:%@"; + +"provider.codex.metadata.session_label" = "会话"; +"provider.codex.metadata.weekly_label" = "周额度"; +"provider.codex.metadata.credits_hint" = "积分暂不可用;保持 Codex 运行后会自动刷新。"; +"provider.codex.metadata.toggle_title" = "显示 Codex 用量"; +"provider.codex.no_data_message" = "在 %@ 或 %@ 中未找到 Codex 会话。"; + +"provider.codex.toggle.openai_web_extras.title" = "OpenAI 网页扩展"; +"provider.codex.toggle.openai_web_extras.subtitle" = "在 chatgpt.com 显示用量拆分、积分历史和代码审查信息。"; + +"provider.codex.picker.usage_source.title" = "用量来源"; +"provider.codex.picker.usage_source.subtitle" = "自动模式会在首选来源失败时回退到下一个来源。"; + +"provider.codex.picker.cookie_source.title" = "OpenAI Cookies"; +"provider.codex.picker.cookie_source.subtitle" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。"; +"provider.codex.picker.cookie_source.auto" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。"; +"provider.codex.picker.cookie_source.manual" = "粘贴 chatgpt.com 请求中的 Cookie 头。"; +"provider.codex.picker.cookie_source.off" = "禁用 OpenAI 仪表盘 Cookie。"; + +"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI 地址"; +"provider.codex.field.cliproxy_url.subtitle" = "管理 API 基础地址(默认 http://127.0.0.1:8317)。"; +"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317"; +"provider.codex.field.cliproxy_management_key.title" = "CLIProxy 管理密钥"; +"provider.codex.field.cliproxy_management_key.subtitle" = "以 Authorization Bearer 形式发送到 /v0/management/* 接口。"; +"provider.codex.field.cliproxy_management_key.placeholder" = "粘贴管理密钥…"; +"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index(可选)"; +"provider.codex.field.cliproxy_auth_index.subtitle" = "留空时自动选择 Codex 认证条目。"; +"provider.codex.field.cliproxy_auth_index.placeholder" = "可选 auth_index"; +"provider.codex.field.cookie_header.placeholder" = "Cookie:…"; + +"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI 地址无效。"; +"error.codex.cliproxy.missing_management_key" = "缺少 CLIProxy 管理密钥。请到 设置 > General > CLIProxyAPI 中填写。"; +"error.codex.cliproxy.invalid_response" = "CLIProxyAPI 返回了无效响应。"; +"error.codex.cliproxy.management_failed" = "CLIProxyAPI 管理接口请求失败(%d)。"; +"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI 管理接口请求失败(%d):%@"; +"error.codex.cliproxy.missing_auth" = "CLIProxyAPI 中没有可用的 Codex 认证条目。"; +"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI 未找到 Codex auth_index %@。"; +"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call 请求失败(%d)。"; +"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call 请求失败(%d):%@"; +"error.codex.cliproxy.decode_failed" = "解析 CLIProxyAPI 响应失败:%@"; + +"error.codex.status.missing_cli" = "未检测到 Codex CLI。请执行 `npm i -g @openai/codex`(或 bun 安装)后重试。"; +"error.codex.status.parse_failed" = "无法解析 Codex 状态,将稍后重试。"; +"error.codex.status.timed_out" = "Codex 状态探测超时。"; +"error.codex.status.update_required" = "需要更新 Codex CLI:%@"; +"error.codex.status.update_required_action" = "请执行 `bun install -g @openai/codex` 后继续(更新提示会阻塞 /status)。"; + +"error.codex.rpc.start_failed" = "Codex 未运行。请先运行一次 Codex 命令。(%@)"; +"error.codex.rpc.request_failed" = "Codex 连接失败:%@"; +"error.codex.rpc.malformed" = "Codex 返回了无效数据:%@"; +"error.codex.rpc.chatgpt_auth_required" = "需要 ChatGPT 登录才能读取限额。"; + +"error.codex.openai_web.login_required" = "OpenAI 网页访问需要登录。"; +"error.codex.openai_web.no_data_with_body" = "未找到 OpenAI 仪表盘数据。返回内容示例:%@"; +"error.codex.openai_web.unsupported_platform" = "OpenAI 网页仪表盘仅支持在 macOS 上抓取。"; + +"menu.action.switch_account" = "切换账号..."; +"menu.action.add_account" = "添加账号..."; +"menu.action.usage_dashboard" = "用量仪表盘"; +"menu.action.status_page" = "状态页"; +"menu.action.install_update" = "发现更新,立即重启?"; +"menu.action.settings" = "设置..."; +"menu.action.about" = "关于 CodexBar"; +"menu.action.quit" = "退出"; +"menu.action.buy_credits" = "购买积分..."; +"menu.action.credits_history" = "积分历史"; +"menu.action.usage_breakdown" = "用量拆分"; +"menu.codex.cliproxy.auth_grid.title" = "Codex 认证条目(%d 个)"; + +"menu.card.percent.left" = "剩余"; +"menu.card.percent.used" = "已用"; +"menu.card.accessibility.usage_remaining" = "用量剩余"; +"menu.card.accessibility.usage_used" = "用量已用"; +"menu.card.accessibility.credits_remaining" = "积分剩余"; +"menu.card.credits.title" = "积分"; +"menu.card.cost.title" = "成本"; +"menu.card.tokens.unit" = "%@ tokens"; +"menu.card.cost.today_with_tokens" = "今天:%@ · %@ tokens"; +"menu.card.cost.today" = "今天:%@"; +"menu.card.cost.last_30_days_with_tokens" = "近 30 天:%@ · %@ tokens"; +"menu.card.cost.last_30_days" = "近 30 天:%@"; +"menu.card.provider_cost.quota_usage" = "配额使用"; +"menu.card.provider_cost.extra_usage" = "额外用量"; +"menu.card.provider_cost.this_month" = "本月"; + +"window.buy_credits.title" = "购买积分"; + +"settings.general.language.title" = "语言"; +"settings.general.language.subtitle" = "选择应用界面语言。"; +"settings.general.language.restart_hint" = "语言变更在重启后生效。"; +"settings.general.language.apply_restart" = "应用并重启"; +"settings.general.language.option.system" = "跟随系统"; +"settings.general.language.option.english" = "English"; +"settings.general.language.option.zh_hans" = "简体中文"; + +"settings.general.cliproxy.section" = "CLIProxyAPI"; +"settings.general.cliproxy.url.title" = "基础地址"; +"settings.general.cliproxy.url.subtitle" = "作为使用 API 源的 Provider(例如 Codex)的全局默认值。"; +"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317"; +"settings.general.cliproxy.key.title" = "管理密钥"; +"settings.general.cliproxy.key.placeholder" = "粘贴 management key…"; +"settings.general.cliproxy.auth_index.title" = "auth_index(可选)"; +"settings.general.cliproxy.auth_index.subtitle" = "可选。指定某个认证文件;留空则聚合所有 Codex 认证条目。"; +"settings.general.cliproxy.auth_index.placeholder" = "留空将加载全部可用 Codex 认证条目"; + +"provider.codex.cliproxy.aggregate.account_label" = "全部 Codex 认证条目(%d 个)"; + +"settings.general.system.section" = "系统"; +"settings.general.system.start_at_login.title" = "开机启动"; +"settings.general.system.start_at_login.subtitle" = "在 Mac 启动时自动打开 CodexBar。"; +"settings.general.usage.section" = "用量"; +"settings.general.usage.cost_summary.title" = "显示成本摘要"; +"settings.general.usage.cost_summary.subtitle" = "读取本地 usage 日志,在菜单中显示今天和近 30 天成本。"; +"settings.general.usage.cost_summary.refresh_hint" = "自动刷新:每小时 · 超时:10 分钟"; +"settings.general.automation.section" = "自动化"; +"settings.general.automation.refresh_cadence.title" = "刷新频率"; +"settings.general.automation.refresh_cadence.subtitle" = "CodexBar 在后台轮询各 Provider 的频率。"; +"settings.general.automation.refresh_cadence.manual_hint" = "已关闭自动刷新;可使用菜单中的 Refresh 手动刷新。"; +"settings.general.automation.check_status.title" = "检查服务状态"; +"settings.general.automation.check_status.subtitle" = "轮询 OpenAI/Claude 状态页和 Gemini/Antigravity 对应的 Google Workspace 状态,并在图标与菜单中提示故障。"; +"settings.general.automation.session_quota.title" = "会话配额通知"; +"settings.general.automation.session_quota.subtitle" = "当 5 小时会话额度降至 0% 或恢复可用时发送通知。"; +"settings.general.quit" = "退出 CodexBar"; +"settings.general.usage.cost_status.unsupported" = "%@:不支持"; +"settings.general.usage.cost_status.fetching" = "%@:获取中…%@"; +"settings.general.usage.cost_status.snapshot" = "%@:%@ · 30 天 %@"; +"settings.general.usage.cost_status.last_attempt" = "%@:上次尝试 %@"; +"settings.general.usage.cost_status.no_data" = "%@:暂无数据"; + +"settings.general.refresh_frequency.manual" = "手动"; +"settings.general.refresh_frequency.one_minute" = "1 分钟"; +"settings.general.refresh_frequency.two_minutes" = "2 分钟"; +"settings.general.refresh_frequency.five_minutes" = "5 分钟"; +"settings.general.refresh_frequency.fifteen_minutes" = "15 分钟"; +"settings.general.refresh_frequency.thirty_minutes" = "30 分钟"; + +"settings.providers.menu_bar_metric.title" = "菜单栏指标"; +"settings.providers.menu_bar_metric.subtitle" = "选择菜单栏百分比依据的窗口。"; +"settings.providers.menu_bar_metric.option.automatic" = "自动"; +"settings.providers.menu_bar_metric.option.primary" = "主窗口"; +"settings.providers.menu_bar_metric.option.secondary" = "次窗口"; +"settings.providers.menu_bar_metric.option.average" = "平均"; +"settings.providers.menu_bar_metric.option.primary_with_label" = "主窗口(%@)"; +"settings.providers.menu_bar_metric.option.secondary_with_label" = "次窗口(%@)"; +"settings.providers.menu_bar_metric.option.average_with_labels" = "平均(%@ + %@)"; diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..7760a33b7 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -282,13 +282,34 @@ private enum RPCWireError: Error, LocalizedError { var errorDescription: String? { switch self { case let .startFailed(message): - "Codex not running. Try running a Codex command first. (\(message))" + let format = L10n.tr( + "error.codex.rpc.start_failed", + fallback: "Codex not running. Try running a Codex command first. (%@)") + return String(format: format, locale: .current, Self.localizedMessage(message)) case let .requestFailed(message): - "Codex connection failed: \(message)" + let format = L10n.tr( + "error.codex.rpc.request_failed", + fallback: "Codex connection failed: %@") + return String(format: format, locale: .current, Self.localizedMessage(message)) case let .malformed(message): - "Codex returned invalid data: \(message)" + let format = L10n.tr( + "error.codex.rpc.malformed", + fallback: "Codex returned invalid data: %@") + return String(format: format, locale: .current, Self.localizedMessage(message)) } } + + private static func localizedMessage(_ message: String) -> String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return message } + let normalized = trimmed.lowercased() + if normalized.contains("chatgpt authentication required to read rate limits") { + return L10n.tr( + "error.codex.rpc.chatgpt_auth_required", + fallback: "ChatGPT authentication required to read rate limits.") + } + return trimmed + } } /// RPC helper used on background tasks; safe because we confine it to the owning task. diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index a735f6a19..c68d832ff 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -42,6 +42,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: updatedSnap, credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, @@ -103,6 +104,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -153,6 +155,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -195,6 +198,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -223,6 +227,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: nil, credits: nil, creditsError: nil, @@ -264,6 +269,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -291,6 +297,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: nil, credits: nil, creditsError: nil, @@ -331,6 +338,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, @@ -371,6 +379,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, @@ -410,6 +419,7 @@ struct MenuCardModelTests { let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, + sourceLabel: nil, snapshot: snapshot, credits: nil, creditsError: nil, diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 9e05c7462..9c2d035bd 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -123,6 +123,50 @@ struct ProviderSettingsDescriptorTests { #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) } + @Test + func codexHidesCLIProxyFieldsWhenAPISelected() throws { + let suite = "ProviderSettingsDescriptorTests-codex-cliproxy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexUsageDataSource = .api + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let fields = CodexProviderImplementation().settingsFields(context: context) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-base-url" }) == false) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-management-key" }) == false) + #expect(fields.contains(where: { $0.id == "codex-cliproxy-auth-index" }) == false) + } + @Test func claudeExposesUsageAndCookiePickers() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index fad99a763..93f68f547 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -143,6 +143,36 @@ struct SettingsStoreTests { #expect(store.codexUsageDataSource == .auto) } + @Test + func persistsCodexCLIProxySettingsAcrossInstances() throws { + let suite = "SettingsStoreTests-codex-cliproxy" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + storeA.codexUsageDataSource = .api + storeA.codexCLIProxyBaseURL = "http://127.0.0.1:8317" + storeA.codexCLIProxyManagementKey = "test-management-key" + storeA.codexCLIProxyAuthIndex = "auth-index-123" + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.codexUsageDataSource == .api) + #expect(storeB.codexCLIProxyBaseURL == "http://127.0.0.1:8317") + #expect(storeB.codexCLIProxyManagementKey == "test-management-key") + #expect(storeB.codexCLIProxyAuthIndex == "auth-index-123") + } + @Test @MainActor func applyExternalConfigDoesNotBroadcast() throws { diff --git a/docs/codex.md b/docs/codex.md index 9b23ed49c..204f42459 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -1,5 +1,5 @@ --- -summary: "Codex provider data sources: OpenAI web dashboard, Codex CLI RPC/PTY, credits, and local cost usage." +summary: "Codex provider data sources: OAuth API, CLIProxyAPI management API, OpenAI web dashboard, CLI RPC/PTY, and local cost usage." read_when: - Debugging Codex usage/credits parsing - Updating OpenAI dashboard scraping or cookie import @@ -9,7 +9,7 @@ read_when: # Codex provider -Codex has four usage data paths (OAuth API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner. +Codex has five usage data paths (OAuth API, CLIProxyAPI management API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner. The OAuth API is the default app source when credentials are available; web access is optional for dashboard extras. ## Data sources + fallback order @@ -21,12 +21,24 @@ The OAuth API is the default app source when credentials are available; web acce `primary + openai-web`. Usage source picker: -- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLI). +- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLIProxyAPI/CLI). ### CLI default selection (`--source auto`) 1) OpenAI web dashboard (when available). 2) Codex CLI RPC, with CLI PTY fallback when needed. +### CLIProxyAPI management API +- Usage source: `CLIProxyAPI` (`--source api`). +- Settings: + - `CLIProxyAPI URL` (default `http://127.0.0.1:8317`). + - `CLIProxy management key` (required). + - `CLIProxy auth_index` (optional). + - CLI (`codexbar --source api`) 留空时会遍历并输出所有可用 Codex auth 条目。 + - App 运行时留空会自动选择第一个可用 Codex auth 条目。 +- Calls: + 1) `GET /v0/management/auth-files` to resolve a Codex `auth_index`. + 2) `POST /v0/management/api-call` to proxy `GET https://chatgpt.com/backend-api/wham/usage`. + ### OAuth API (preferred for the app) - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). - Refreshes access tokens when `last_refresh` is older than 8 days. From 857da517f7199970db08a4c00694b04016c207e4 Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 02:12:21 +0800 Subject: [PATCH 2/8] Implement CodexProxy support and enhance provider handling - Introduced CodexProxy as a new provider, including its implementation and descriptor. - Updated existing code to accommodate CodexProxy in various functionalities, such as usage tracking and settings management. - Enhanced the handling of provider-specific logic across multiple files to ensure compatibility with CodexProxy. - Migrated legacy Codex CLI Proxy defaults to streamline configuration. - Improved UI elements to reflect CodexProxy integration, including menu and widget updates. - Refined error handling and logging for CodexProxy usage scenarios. --- Sources/CodexBar/MenuCardView.swift | 12 ++- Sources/CodexBar/MenuDescriptor.swift | 2 +- .../Codex/CodexProviderImplementation.swift | 4 +- .../CodexProxyProviderImplementation.swift | 28 +++++++ .../Providers/Codex/CodexSettingsStore.swift | 41 ++++++++-- .../ProviderImplementationRegistry.swift | 1 + Sources/CodexBar/SettingsStore.swift | 1 + .../StatusItemController+Actions.swift | 4 +- .../CodexBar/StatusItemController+Menu.swift | 23 ++++-- Sources/CodexBar/UsageStore+Refresh.swift | 64 ++++++++------- Sources/CodexBar/UsageStore.swift | 6 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 9 +++ Sources/CodexBarCore/CostUsageFetcher.swift | 13 ++-- .../Codex/CodexProviderDescriptor.swift | 11 ++- .../Codex/CodexProxyProviderDescriptor.swift | 78 +++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 1 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 20 files changed, 249 insertions(+), 56 deletions(-) create mode 100644 Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5d6bb62d0..16388ca02 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -627,6 +627,8 @@ extension UsageMenuCardView.Model { } let tokenUsage = Self.tokenUsageSection( provider: input.provider, + sourceLabel: input.sourceLabel, + hasUsageSnapshot: input.snapshot != nil, enabled: input.tokenCostUsageEnabled, snapshot: input.tokenSnapshot, error: input.tokenError) @@ -880,11 +882,19 @@ extension UsageMenuCardView.Model { private static func tokenUsageSection( provider: UsageProvider, + sourceLabel: String?, + hasUsageSnapshot: Bool, enabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { + return nil + } + if provider == .codex { + guard hasUsageSnapshot else { return nil } + if sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") == true { return nil } + } guard enabled else { return nil } guard let snapshot else { return nil } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 0848fd050..57cf14890 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -324,7 +324,7 @@ struct MenuDescriptor { } private static func shouldHideSwitchAccountAction(provider: UsageProvider?, store: UsageStore) -> Bool { - guard provider == .codex else { return false } + guard provider == .codexproxy else { return false } let codexSettings = store.settings.codexSettingsSnapshot(tokenOverride: nil) return CodexCLIProxySettings.resolve( providerSettings: codexSettings, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 2bb6f3fcb..921460c98 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -53,7 +53,7 @@ struct CodexProviderImplementation: ProviderImplementation { switch context.settings.codexUsageDataSource { case .auto: .auto case .oauth: .oauth - case .api: .api + case .api: .auto case .cli: .cli } } @@ -105,7 +105,7 @@ struct CodexProviderImplementation: ProviderImplementation { context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) - let usageOptions = CodexUsageDataSource.allCases.map { + let usageOptions = CodexUsageDataSource.allCases.filter { $0 != .api }.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } let cookieOptions = ProviderCookieSourceUI.options( diff --git a/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift new file mode 100644 index 000000000..1b128d4c5 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct CodexProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .codexproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 10e49694a..5113062c6 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -81,19 +81,25 @@ extension SettingsStore { extension SettingsStore { func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings { let resolvedBaseURL: String = { + let globalValue = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } let providerValue = self.codexCLIProxyBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) if !providerValue.isEmpty { return providerValue } - return self.cliProxyGlobalBaseURL + return globalValue }() let resolvedManagementKey: String = { + let globalValue = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } let providerValue = self.codexCLIProxyManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) if !providerValue.isEmpty { return providerValue } - return self.cliProxyGlobalManagementKey + return globalValue }() let resolvedAuthIndex: String = { + let globalValue = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if !globalValue.isEmpty { return globalValue } let providerValue = self.codexCLIProxyAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) if !providerValue.isEmpty { return providerValue } - return self.cliProxyGlobalAuthIndex + return globalValue }() return ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, @@ -107,10 +113,8 @@ extension SettingsStore { private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { guard let source else { return .auto } switch source { - case .auto, .web: + case .auto, .web, .api: return .auto - case .api: - return .api case .cli: return .cli case .oauth: @@ -145,4 +149,29 @@ extension SettingsStore { if self.tokenAccounts(for: .codex).isEmpty { return fallback } return .manual } + + func migrateLegacyCodexCLIProxyDefaultsIfNeeded() { + guard let entry = self.configSnapshot.providerConfig(for: .codex) else { return } + + let legacyBaseURL = entry.sanitizedAPIBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let legacyManagementKey = entry.sanitizedAPIKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let legacyAuthIndex = entry.sanitizedAPIAuthIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let globalBaseURL = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldAdoptLegacyBaseURL = globalBaseURL.isEmpty || + (globalBaseURL == CodexCLIProxySettings.defaultBaseURL && !legacyBaseURL.isEmpty && legacyBaseURL != globalBaseURL) + if shouldAdoptLegacyBaseURL, !legacyBaseURL.isEmpty { + self.cliProxyGlobalBaseURL = legacyBaseURL + } + + let globalManagementKey = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + if globalManagementKey.isEmpty, !legacyManagementKey.isEmpty { + self.cliProxyGlobalManagementKey = legacyManagementKey + } + + let globalAuthIndex = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines) + if globalAuthIndex.isEmpty, !legacyAuthIndex.isEmpty { + self.cliProxyGlobalAuthIndex = legacyAuthIndex + } + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3b..5cced7487 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -13,6 +13,7 @@ enum ProviderImplementationRegistry { private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) { switch provider { case .codex: CodexProviderImplementation() + case .codexproxy: CodexProxyProviderImplementation() case .claude: ClaudeProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f48e9c7f8..164ab4342 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -171,6 +171,7 @@ final class SettingsStore { self.configLoading = true self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) self.updateProviderState(config: config) + self.migrateLegacyCodexCLIProxyDefaultsIfNeeded() self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) userDefaults.removeObject(forKey: "showCodexUsage") diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 4e6719902..5dbd72074 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -32,7 +32,9 @@ extension StatusItemController { let meta = self.store.metadata(for: provider) // For Claude, route subscription users to claude.ai/settings/usage instead of console billing - let urlString: String? = if provider == .codex, let cliProxyDashboardURL = self.codexCLIProxyUsageDashboardURL() { + let urlString: String? = if provider == .codexproxy, + let cliProxyDashboardURL = self.codexCLIProxyUsageDashboardURL() + { cliProxyDashboardURL.absoluteString } else if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 41f5026f5..825ed45e2 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -521,10 +521,10 @@ extension StatusItemController { } private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { - if provider == .codex, + if (provider == .codex || provider == .codexproxy), let snapshots = self.store.accountSnapshots[provider], snapshots.count > 1, - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") { return TokenAccountMenuDisplay( provider: provider, @@ -554,9 +554,9 @@ extension StatusItemController { provider: UsageProvider, display: TokenAccountMenuDisplay) -> Bool { - provider == .codex && + (provider == .codex || provider == .codexproxy) && display.showAll && - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") + self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") } private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { @@ -1291,7 +1291,9 @@ extension StatusItemController { } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .codexproxy || 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 } @@ -1329,7 +1331,9 @@ extension StatusItemController { private func makeCostHistoryInlineItem(provider: UsageProvider, width: CGFloat) -> NSMenuItem? { guard Self.menuCardRenderingEnabled else { return nil } - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { + return nil + } guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } guard !tokenSnapshot.daily.isEmpty else { return nil } @@ -1402,6 +1406,13 @@ extension StatusItemController { dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) + } else if target == .codexproxy, snapshotOverride == nil { + credits = nil + creditsError = nil + dashboard = nil + dashboardError = nil + tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenError = self.store.tokenError(for: target) } else if target == .claude || target == .vertexai, snapshotOverride == nil { credits = nil creditsError = nil diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 2db927ba7..cfdb2a849 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -128,13 +128,15 @@ extension UsageStore { } private func refreshCodexCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CodexCLIProxyMultiAuthRefreshState { - guard provider == .codex else { return .notHandled } - guard self.sourceMode(for: .codex) == .api else { return .notHandled } + guard provider == .codex || provider == .codexproxy else { return .notHandled } + if provider == .codex, self.sourceMode(for: .codex) != .api { + return .notHandled + } let settingsSnapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) let env = ProviderRegistry.makeEnvironment( base: ProcessInfo.processInfo.environment, - provider: .codex, + provider: provider, settings: self.settings, tokenOverride: nil) @@ -171,8 +173,8 @@ extension UsageStore { let account = self.codexCLIProxyAccount(for: auth) do { let usage = try await client.fetchCodexUsage(auth: auth) - let mapped = self.codexUsageSnapshot(from: usage, auth: auth) - let labeled = self.applyAccountLabel(mapped, provider: .codex, account: account) + let mapped = self.codexUsageSnapshot(from: usage, auth: auth, provider: provider) + let labeled = self.applyAccountLabel(mapped, provider: provider, account: account) successfulUsageSnapshots.append(labeled) if let credits = self.codexCreditsSnapshot(from: usage) { creditBalances.append(credits.remaining) @@ -200,31 +202,36 @@ extension UsageStore { if let aggregate = self.aggregateCodexCLIProxySnapshot( successfulUsageSnapshots, + provider: provider, totalAuthCount: auths.count) { await MainActor.run { - self.handleSessionQuotaTransition(provider: .codex, snapshot: aggregate) - self.snapshots[.codex] = aggregate - self.accountSnapshots[.codex] = accountSnapshots - self.lastSourceLabels[.codex] = "cliproxy-api" - self.lastFetchAttempts[.codex] = [] - self.errors[.codex] = nil - self.credits = aggregatedCredits - self.lastCreditsError = nil - self.failureGates[.codex]?.recordSuccess() + self.handleSessionQuotaTransition(provider: provider, snapshot: aggregate) + self.snapshots[provider] = aggregate + self.accountSnapshots[provider] = accountSnapshots + self.lastSourceLabels[provider] = "cliproxy-api" + self.lastFetchAttempts[provider] = [] + self.errors[provider] = nil + if provider == .codex { + self.credits = aggregatedCredits + self.lastCreditsError = nil + } + self.failureGates[provider]?.recordSuccess() } return .success } let resolvedError = firstError ?? CodexCLIProxyError.missingCodexAuth(nil) await MainActor.run { - self.snapshots.removeValue(forKey: .codex) - self.accountSnapshots[.codex] = accountSnapshots - self.lastSourceLabels[.codex] = "cliproxy-api" - self.lastFetchAttempts[.codex] = [] - self.errors[.codex] = resolvedError.localizedDescription - self.credits = nil - self.lastCreditsError = nil + self.snapshots.removeValue(forKey: provider) + self.accountSnapshots[provider] = accountSnapshots + self.lastSourceLabels[provider] = "cliproxy-api" + self.lastFetchAttempts[provider] = [] + self.errors[provider] = resolvedError.localizedDescription + if provider == .codex { + self.credits = nil + self.lastCreditsError = nil + } } return .failure(resolvedError) } @@ -247,6 +254,7 @@ extension UsageStore { private func aggregateCodexCLIProxySnapshot( _ snapshots: [UsageSnapshot], + provider: UsageProvider, totalAuthCount: Int) -> UsageSnapshot? { guard !snapshots.isEmpty else { return nil } @@ -257,7 +265,7 @@ extension UsageStore { let loginMethods = Set( snapshots.compactMap { snapshot in - snapshot.loginMethod(for: .codex)? + snapshot.loginMethod(for: provider)? .trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) let loginMethod = loginMethods.count == 1 ? loginMethods.first : nil @@ -267,7 +275,7 @@ extension UsageStore { fallback: "All Codex auth entries (%d)") let accountLabel = String(format: accountLabelFormat, locale: .current, totalAuthCount) let identity = ProviderIdentitySnapshot( - providerID: .codex, + providerID: provider, accountEmail: accountLabel, accountOrganization: nil, loginMethod: loginMethod) @@ -293,7 +301,11 @@ extension UsageStore { resetDescription: resetDescription) } - private func codexUsageSnapshot(from usage: CodexUsageResponse, auth: CodexCLIProxyResolvedAuth) -> UsageSnapshot { + private func codexUsageSnapshot( + from usage: CodexUsageResponse, + auth: CodexCLIProxyResolvedAuth, + provider: UsageProvider) -> UsageSnapshot + { let primary = self.codexRateWindow(from: usage.rateLimit?.primaryWindow) ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = self.codexRateWindow(from: usage.rateLimit?.secondaryWindow) @@ -303,7 +315,7 @@ extension UsageStore { let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines) let identity = ProviderIdentitySnapshot( - providerID: .codex, + providerID: provider, accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, accountOrganization: nil, loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod) @@ -313,7 +325,7 @@ extension UsageStore { tertiary: nil, updatedAt: Date(), identity: identity) - .scoped(to: .codex) + .scoped(to: provider) } private func codexRateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index fece1aac4..2308ab213 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1149,6 +1149,10 @@ extension UsageStore { let raw = await self.codexFetcher.debugRawRateLimits() await MainActor.run { self.probeLogs[.codex] = raw } return raw + case .codexproxy: + let text = "CLIProxy Codex uses management API; use CodexBarCLI --provider codexproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.codexproxy] = text } + return text case .claude: let text = await self.debugClaudeLog( claudeWebExtrasEnabled: claudeWebExtrasEnabled, @@ -1514,7 +1518,7 @@ extension UsageStore { } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index fb1486441..c826c5fa3 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -91,6 +91,15 @@ struct TokenAccountCLIContext { cliProxyBaseURL: config?.sanitizedAPIBaseURL, cliProxyManagementKey: config?.sanitizedAPIKey, cliProxyAuthIndex: config?.sanitizedAPIAuthIndex)) + case .codexproxy: + return self.makeSnapshot( + codex: ProviderSettingsSnapshot.CodexProviderSettings( + usageDataSource: .api, + cookieSource: .off, + manualCookieHeader: nil, + cliProxyBaseURL: config?.sanitizedAPIBaseURL, + cliProxyManagementKey: config?.sanitizedAPIKey, + cliProxyAuthIndex: config?.sanitizedAPIAuthIndex)) case .claude: let claudeSource: ClaudeUsageDataSource = if provider == .claude, let account, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..52824dd07 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -26,18 +26,19 @@ public struct CostUsageFetcher: Sendable { forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else { throw CostUsageError.unsupportedProvider(provider) } + let scannedProvider: UsageProvider = (provider == .codexproxy) ? .codex : provider let until = now // Rolling window: last 30 days (inclusive). Use -29 for inclusive boundaries. let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now var options = CostUsageScanner.Options() - if provider == .vertexai { + if scannedProvider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly - } else if provider == .claude { + } else if scannedProvider == .claude { options.claudeLogProviderFilter = .excludeVertexAI } if forceRefresh { @@ -45,13 +46,13 @@ public struct CostUsageFetcher: Sendable { options.forceRescan = true } var daily = CostUsageScanner.loadDailyReport( - provider: provider, + provider: scannedProvider, since: since, until: until, now: now, options: options) - if provider == .vertexai, + if scannedProvider == .vertexai, !allowVertexClaudeFallback, options.claudeLogProviderFilter == .vertexAIOnly, daily.data.isEmpty @@ -59,7 +60,7 @@ public struct CostUsageFetcher: Sendable { var fallback = options fallback.claudeLogProviderFilter = .all daily = CostUsageScanner.loadDailyReport( - provider: provider, + provider: scannedProvider, since: since, until: until, now: now, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index a8410b626..a9358c00e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -34,7 +34,7 @@ public enum CodexProviderDescriptor { supportsTokenCost: true, noDataMessage: self.noDataMessage), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web, .cli, .oauth, .api], + sourceModes: [.auto, .web, .cli, .oauth], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "codex", @@ -44,7 +44,6 @@ public enum CodexProviderDescriptor { private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { let cli = CodexCLIUsageStrategy() let oauth = CodexOAuthFetchStrategy() - let api = CodexCLIProxyFetchStrategy() let web = CodexWebDashboardStrategy() switch context.runtime { @@ -53,26 +52,26 @@ public enum CodexProviderDescriptor { case .oauth: return [oauth] case .api: - return [api] + return [web, cli] case .web: return [web] case .cli: return [cli] case .auto: - return [api, web, cli] + return [web, cli] } case .app: switch context.sourceMode { case .oauth: return [oauth] case .api: - return [api] + return [oauth, cli] case .cli: return [cli] case .web: return [web] case .auto: - return [oauth, api, cli] + return [oauth, cli] } } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift new file mode 100644 index 000000000..f0cc44e4b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift @@ -0,0 +1,78 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CodexProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .codexproxy, + metadata: ProviderMetadata( + id: .codexproxy, + displayName: "CLIProxy Codex", + sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"), + weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"), + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Codex usage", + cliName: "codex-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .codex, + iconResourceName: "ProviderIcon-codex", + color: ProviderColor(red: 73 / 255, green: 163 / 255, blue: 176 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "No Codex sessions found in local logs for CLIProxy Codex." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodexProxyCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "codex-proxy", + aliases: ["cliproxy-codex"], + versionDetector: nil)) + } +} + +private struct CodexProxyCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "codexproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveCodexAuth() + let usage = try await client.fetchCodexUsage(auth: auth) + let snapshot = CodexUsageSnapshotMapper.usageSnapshot( + from: usage, + accountEmail: auth.email, + fallbackLoginMethod: auth.planType) + .scoped(to: .codexproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff83695..f8ce6085a 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -54,6 +54,7 @@ public enum ProviderDescriptorRegistry { private static let store = Store() private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, + .codexproxy: CodexProxyProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb953..9f70c78ce 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -4,6 +4,7 @@ import SweetCookieKit // swiftformat:disable sortDeclarations public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex + case codexproxy case claude case cursor case opencode diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index d47d7d557..982df9851 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -61,6 +61,8 @@ enum CostUsageScanner { switch provider { case .codex: return self.loadCodexDaily(range: range, now: now, options: options) + case .codexproxy: + return self.loadCodexDaily(range: range, now: now, options: options) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) case .zai: diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611ee..bb73c6423 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -42,6 +42,7 @@ enum ProviderChoice: String, AppEnum { init?(provider: UsageProvider) { switch provider { case .codex: self = .codex + case .codexproxy: return nil // CLIProxy Codex not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini case .antigravity: self = .antigravity diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b4506..1b5b9e09a 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -258,6 +258,7 @@ private struct ProviderSwitchChip: View { private var shortLabel: String { switch self.provider { case .codex: "Codex" + case .codexproxy: "CdxProxy" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" @@ -571,6 +572,8 @@ enum WidgetColors { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .codexproxy: + Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: From 35dc0ab2688867bd00ce62311e737dbfeea4785f Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 04:16:13 +0800 Subject: [PATCH 3/8] Implement Gemini and Antigravity proxy support with enhanced provider handling - Introduced GeminiProxy and AntigravityProxy as new providers, including their implementations and descriptors. - Updated existing code to accommodate these new providers in various functionalities, such as usage tracking and settings management. - Enhanced provider-specific logic across multiple files to ensure compatibility with Gemini and Antigravity proxies. - Improved error handling and logging for usage scenarios related to the new proxies. - Updated localization strings and UI elements to reflect the integration of Gemini and Antigravity proxies. --- Sources/CodexBar/PreferencesGeneralPane.swift | 4 +- ...tigravityProxyProviderImplementation.swift | 28 ++ .../GeminiProxyProviderImplementation.swift | 28 ++ .../ProviderImplementationRegistry.swift | 2 + .../StatusItemController+Actions.swift | 10 +- .../CodexBar/StatusItemController+Menu.swift | 35 ++- Sources/CodexBar/UsageStore+Refresh.swift | 108 +++++-- Sources/CodexBar/UsageStore.swift | 10 + Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../AntigravityProxyProviderDescriptor.swift | 77 +++++ .../Codex/CodexCLIProxyManagementClient.swift | 291 ++++++++++++++++-- .../CLIProxyGeminiQuotaSnapshotMapper.swift | 65 ++++ .../GeminiProxyProviderDescriptor.swift | 77 +++++ .../Providers/ProviderDescriptor.swift | 2 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Resources/en.lproj/Localizable.strings | 8 +- .../zh-Hans.lproj/Localizable.strings | 8 +- .../Vendored/CostUsage/CostUsageScanner.swift | 4 + .../CodexBarWidgetProvider.swift | 2 + .../CodexBarWidget/CodexBarWidgetViews.swift | 6 + 20 files changed, 704 insertions(+), 65 deletions(-) create mode 100644 Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift create mode 100644 Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift create mode 100644 Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 69b5042a2..e7199eada 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -99,13 +99,13 @@ struct GeneralPane: View { .font(.body) Text(L10n.tr( "settings.general.cliproxy.auth_index.subtitle", - fallback: "Optional. Set a specific auth file; leave empty to aggregate all Codex auth entries.")) + fallback: "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries.")) .font(.footnote) .foregroundStyle(.tertiary) TextField( L10n.tr( "settings.general.cliproxy.auth_index.placeholder", - fallback: "Leave empty to load all available Codex auth entries"), + fallback: "Leave empty to load all matching auth entries"), text: self.$settings.cliProxyGlobalAuthIndex) .textFieldStyle(.roundedBorder) .font(.footnote) diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift new file mode 100644 index 000000000..7fb601c06 --- /dev/null +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct AntigravityProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .antigravityproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift new file mode 100644 index 000000000..658262867 --- /dev/null +++ b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct GeminiProxyProviderImplementation: ProviderImplementation { + let id: UsageProvider = .geminiproxy + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.cliProxyGlobalBaseURL + _ = settings.cliProxyGlobalManagementKey + _ = settings.cliProxyGlobalAuthIndex + _ = settings.codexCLIProxyBaseURL + _ = settings.codexCLIProxyManagementKey + _ = settings.codexCLIProxyAuthIndex + } + + @MainActor + func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? { + "cliproxy-api" + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 5cced7487..0334d0c93 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -14,6 +14,8 @@ enum ProviderImplementationRegistry { switch provider { case .codex: CodexProviderImplementation() case .codexproxy: CodexProxyProviderImplementation() + case .geminiproxy: GeminiProxyProviderImplementation() + case .antigravityproxy: AntigravityProxyProviderImplementation() case .claude: ClaudeProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 5dbd72074..18def1dd6 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -32,8 +32,8 @@ extension StatusItemController { let meta = self.store.metadata(for: provider) // For Claude, route subscription users to claude.ai/settings/usage instead of console billing - let urlString: String? = if provider == .codexproxy, - let cliProxyDashboardURL = self.codexCLIProxyUsageDashboardURL() + let urlString: String? = if self.isCLIProxyDashboardProvider(provider), + let cliProxyDashboardURL = self.cliProxyUsageDashboardURL() { cliProxyDashboardURL.absoluteString } else if provider == .claude, self.store.isClaudeSubscription() { @@ -74,7 +74,11 @@ extension StatusItemController { return url.absoluteString } - private func codexCLIProxyUsageDashboardURL() -> URL? { + private func isCLIProxyDashboardProvider(_ provider: UsageProvider) -> Bool { + provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func cliProxyUsageDashboardURL() -> URL? { let providerSettings = self.settings.codexSettingsSnapshot(tokenOverride: nil) guard let cliProxySettings = CodexCLIProxySettings.resolve( providerSettings: providerSettings, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 825ed45e2..a68cf13e4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -279,7 +279,7 @@ extension StatusItemController { private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots - let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( + let shouldShowAggregateCard = self.isCLIProxyMultiAuthDisplay( provider: context.currentProvider, display: tokenAccountDisplay) if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { @@ -292,9 +292,14 @@ extension StatusItemController { } } if shouldShowAggregateCard { - let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) + let entries = self.codexCLIProxyCompactEntries( + from: accountSnapshots, + provider: context.currentProvider) if !entries.isEmpty { - let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) + let providerName = self.store.metadata(for: context.currentProvider).displayName + let compactView = CodexCLIProxyAuthCompactGridView( + providerDisplayName: providerName, + entries: entries) menu.addItem(self.makeMenuCardItem( compactView, id: "menuCard-auth-grid", @@ -521,7 +526,7 @@ extension StatusItemController { } private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { - if (provider == .codex || provider == .codexproxy), + if self.isCLIProxyMultiAuthProvider(provider), let snapshots = self.store.accountSnapshots[provider], snapshots.count > 1, self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") @@ -550,23 +555,30 @@ extension StatusItemController { showSwitcher: !showAll) } - private func isCodexCLIProxyMultiAuthDisplay( + private func isCLIProxyMultiAuthProvider(_ provider: UsageProvider) -> Bool { + provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func isCLIProxyMultiAuthDisplay( provider: UsageProvider, display: TokenAccountMenuDisplay) -> Bool { - (provider == .codex || provider == .codexproxy) && + self.isCLIProxyMultiAuthProvider(provider) && display.showAll && self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api") } - private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { + private func codexCLIProxyCompactEntries( + from snapshots: [TokenAccountUsageSnapshot], + provider: UsageProvider) -> [CodexCLIProxyAuthCompactGridView.Entry] + { snapshots.map { snapshot in let primary = self.percent(for: snapshot.snapshot?.primary) let secondary = self.percent(for: snapshot.snapshot?.secondary) let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let accountTitle: String if label.isEmpty { - accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" + accountTitle = snapshot.snapshot?.accountEmail(for: provider) ?? provider.rawValue } else { accountTitle = label } @@ -1014,6 +1026,7 @@ extension StatusItemController { let hasError: Bool } + let providerDisplayName: String let entries: [Entry] @Environment(\.menuItemHighlighted) private var isHighlighted @@ -1027,9 +1040,9 @@ extension StatusItemController { var body: some View { VStack(alignment: .leading, spacing: 8) { let titleFormat = L10n.tr( - "menu.codex.cliproxy.auth_grid.title", - fallback: "Codex auth entries (%d)") - Text(String(format: titleFormat, locale: .current, self.entries.count)) + "menu.cliproxy.auth_grid.title", + fallback: "%@ auth entries (%d)") + Text(String(format: titleFormat, locale: .current, self.providerDisplayName, self.entries.count)) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index cfdb2a849..c342d7001 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,7 +2,7 @@ import CodexBarCore import Foundation extension UsageStore { - private enum CodexCLIProxyMultiAuthRefreshState { + private enum CLIProxyMultiAuthRefreshState { case notHandled case success case failure(Error) @@ -39,17 +39,14 @@ extension UsageStore { defer { self.refreshingProviders.remove(provider) } let tokenAccounts = self.tokenAccounts(for: provider) - if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { + let shouldFetchAllTokenAccounts = self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) + if shouldFetchAllTokenAccounts { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) return - } else { - _ = await MainActor.run { - self.accountSnapshots.removeValue(forKey: provider) - } } - let codexCLIProxyMultiAuthState = await self.refreshCodexCLIProxyMultiAuthIfNeeded(provider: provider) - switch codexCLIProxyMultiAuthState { + let cliProxyMultiAuthState = await self.refreshCLIProxyMultiAuthIfNeeded(provider: provider) + switch cliProxyMultiAuthState { case .notHandled: break case .success: @@ -94,6 +91,9 @@ extension UsageStore { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) self.snapshots[provider] = scoped self.lastSourceLabels[provider] = result.sourceLabel + if !shouldFetchAllTokenAccounts { + self.accountSnapshots.removeValue(forKey: provider) + } if provider == .codex { self.credits = result.credits self.lastCreditsError = nil @@ -112,6 +112,9 @@ extension UsageStore { let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + if !shouldFetchAllTokenAccounts { + self.accountSnapshots.removeValue(forKey: provider) + } if shouldSurface { self.errors[provider] = error.localizedDescription self.snapshots.removeValue(forKey: provider) @@ -127,8 +130,8 @@ extension UsageStore { } } - private func refreshCodexCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CodexCLIProxyMultiAuthRefreshState { - guard provider == .codex || provider == .codexproxy else { return .notHandled } + private func refreshCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CLIProxyMultiAuthRefreshState { + guard self.supportsCLIProxyMultiAuth(provider: provider) else { return .notHandled } if provider == .codex, self.sourceMode(for: .codex) != .api { return .notHandled } @@ -152,7 +155,7 @@ extension UsageStore { let client = CodexCLIProxyManagementClient(settings: proxySettings) let auths: [CodexCLIProxyResolvedAuth] do { - auths = try await client.listCodexAuths() + auths = try await self.listCLIProxyAuths(provider: provider, client: client) } catch { return .notHandled } @@ -172,11 +175,11 @@ extension UsageStore { for auth in auths { let account = self.codexCLIProxyAccount(for: auth) do { - let usage = try await client.fetchCodexUsage(auth: auth) - let mapped = self.codexUsageSnapshot(from: usage, auth: auth, provider: provider) + let fetchResult = try await self.cliProxyFetchResult(provider: provider, auth: auth, client: client) + let mapped = fetchResult.snapshot let labeled = self.applyAccountLabel(mapped, provider: provider, account: account) successfulUsageSnapshots.append(labeled) - if let credits = self.codexCreditsSnapshot(from: usage) { + if let credits = fetchResult.credits { creditBalances.append(credits.remaining) } accountSnapshots.append(TokenAccountUsageSnapshot( @@ -221,7 +224,7 @@ extension UsageStore { return .success } - let resolvedError = firstError ?? CodexCLIProxyError.missingCodexAuth(nil) + let resolvedError = firstError ?? self.cliProxyMissingAuthError(for: provider, authIndex: nil) await MainActor.run { self.snapshots.removeValue(forKey: provider) self.accountSnapshots[provider] = accountSnapshots @@ -236,6 +239,74 @@ extension UsageStore { return .failure(resolvedError) } + private func supportsCLIProxyMultiAuth(provider: UsageProvider) -> Bool { + provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy + } + + private func listCLIProxyAuths( + provider: UsageProvider, + client: CodexCLIProxyManagementClient) async throws -> [CodexCLIProxyResolvedAuth] + { + switch provider { + case .codex, .codexproxy: + return try await client.listCodexAuths() + case .geminiproxy: + return try await client.listGeminiAuths() + case .antigravityproxy: + return try await client.listAntigravityAuths() + default: + return [] + } + } + + private func cliProxyMissingAuthError(for provider: UsageProvider, authIndex: String?) -> CodexCLIProxyError { + switch provider { + case .codex, .codexproxy: + return .missingCodexAuth(authIndex) + case .geminiproxy: + return .missingProviderAuth(provider: "Gemini", authIndex: authIndex) + case .antigravityproxy: + return .missingProviderAuth(provider: "Antigravity", authIndex: authIndex) + default: + return .missingCodexAuth(authIndex) + } + } + + private func cliProxyFetchResult( + provider: UsageProvider, + auth: CodexCLIProxyResolvedAuth, + client: CodexCLIProxyManagementClient) async throws -> (snapshot: UsageSnapshot, credits: CreditsSnapshot?) + { + switch provider { + case .codex, .codexproxy: + let usage = try await client.fetchCodexUsage(auth: auth) + return ( + snapshot: self.codexUsageSnapshot(from: usage, auth: auth, provider: provider), + credits: provider == .codex ? self.codexCreditsSnapshot(from: usage) : nil + ) + case .geminiproxy: + let quota = try await client.fetchGeminiQuota(auth: auth) + return ( + snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .geminiproxy), + credits: nil + ) + case .antigravityproxy: + let quota = try await client.fetchAntigravityQuota(auth: auth) + return ( + snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .antigravityproxy), + credits: nil + ) + default: + throw self.cliProxyMissingAuthError(for: provider, authIndex: auth.authIndex) + } + } + private func codexCLIProxyAccount(for auth: CodexCLIProxyResolvedAuth) -> ProviderTokenAccount { ProviderTokenAccount( id: UUID(), @@ -270,10 +341,11 @@ extension UsageStore { }.filter { !$0.isEmpty }) let loginMethod = loginMethods.count == 1 ? loginMethods.first : nil + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let accountLabelFormat = L10n.tr( - "provider.codex.cliproxy.aggregate.account_label", - fallback: "All Codex auth entries (%d)") - let accountLabel = String(format: accountLabelFormat, locale: .current, totalAuthCount) + "provider.cliproxy.aggregate.account_label", + fallback: "All %@ auth entries (%d)") + let accountLabel = String(format: accountLabelFormat, locale: .current, providerName, totalAuthCount) let identity = ProviderIdentitySnapshot( providerID: provider, accountEmail: accountLabel, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2308ab213..b24267b93 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1153,6 +1153,16 @@ extension UsageStore { let text = "CLIProxy Codex uses management API; use CodexBarCLI --provider codexproxy --source api for raw checks." await MainActor.run { self.probeLogs[.codexproxy] = text } return text + case .geminiproxy: + let text = + "CLIProxy Gemini uses management API; use CodexBarCLI --provider geminiproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.geminiproxy] = text } + return text + case .antigravityproxy: + let text = + "CLIProxy Antigravity uses management API; use CodexBarCLI --provider antigravityproxy --source api for raw checks." + await MainActor.run { self.probeLogs[.antigravityproxy] = text } + return text case .claude: let text = await self.debugClaudeLog( claudeWebExtrasEnabled: claudeWebExtrasEnabled, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index c826c5fa3..e631ae754 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -160,7 +160,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .geminiproxy, .antigravityproxy, .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: return nil } } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift new file mode 100644 index 000000000..9376c7a14 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift @@ -0,0 +1,77 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum AntigravityProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .antigravityproxy, + metadata: ProviderMetadata( + id: .antigravityproxy, + displayName: "CLIProxy Antigravity", + sessionLabel: "Pro", + weeklyLabel: "Flash", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Antigravity usage", + cliName: "antigravity-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .antigravity, + iconResourceName: "ProviderIcon-antigravity", + color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Antigravity cost summary is not supported for CLIProxy source." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AntigravityCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "antigravity-proxy", + aliases: ["cliproxy-antigravity"], + versionDetector: nil)) + } +} + +private struct AntigravityCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "antigravityproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveAntigravityAuth() + let quota = try await client.fetchAntigravityQuota(auth: auth) + let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .antigravityproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift index 8c15433b4..babf0c0e8 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift @@ -9,6 +9,7 @@ public enum CodexCLIProxyError: LocalizedError, Sendable { case invalidResponse case managementRequestFailed(Int, String?) case missingCodexAuth(String?) + case missingProviderAuth(provider: String, authIndex: String?) case apiCallFailed(Int, String?) case decodeFailed(String) @@ -43,6 +44,17 @@ public enum CodexCLIProxyError: LocalizedError, Sendable { return L10n.tr( "error.codex.cliproxy.missing_auth", fallback: "CLIProxyAPI has no available Codex auth entry.") + case let .missingProviderAuth(provider, authIndex): + if let authIndex, !authIndex.isEmpty { + let format = L10n.tr( + "error.codex.cliproxy.missing_provider_auth_with_index", + fallback: "CLIProxyAPI did not find %@ auth_index %@.") + return String(format: format, locale: .current, provider, authIndex) + } + let format = L10n.tr( + "error.codex.cliproxy.missing_provider_auth", + fallback: "CLIProxyAPI has no available %@ auth entry.") + return String(format: format, locale: .current, provider) case let .apiCallFailed(status, message): if let message, !message.isEmpty { let format = L10n.tr( @@ -70,9 +82,85 @@ public struct CodexCLIProxyResolvedAuth: Sendable { public let planType: String? } +public struct CLIProxyGeminiQuotaBucket: Sendable { + public let modelID: String + public let remainingFraction: Double + public let resetTime: Date? + + public init(modelID: String, remainingFraction: Double, resetTime: Date?) { + self.modelID = modelID + self.remainingFraction = remainingFraction + self.resetTime = resetTime + } +} + +public struct CLIProxyGeminiQuotaResponse: Sendable { + public let buckets: [CLIProxyGeminiQuotaBucket] + + public init(buckets: [CLIProxyGeminiQuotaBucket]) { + self.buckets = buckets + } +} + +private enum CLIProxyAuthProvider: Sendable { + case codex + case gemini + case antigravity + + var displayName: String { + switch self { + case .codex: "Codex" + case .gemini: "Gemini" + case .antigravity: "Antigravity" + } + } + + var providerValues: Set { + switch self { + case .codex: ["codex"] + case .gemini: ["gemini-cli", "gemini"] + case .antigravity: ["antigravity"] + } + } + + var typeValues: Set { + switch self { + case .codex: ["codex"] + case .gemini: ["gemini-cli", "gemini"] + case .antigravity: ["antigravity"] + } + } + + func matches(provider: String?, type: String?) -> Bool { + let normalizedProvider = provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedType = type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.providerValues.contains(normalizedProvider ?? "") + || self.typeValues.contains(normalizedType ?? "") + } + + func missingAuthError(authIndex: String?) -> CodexCLIProxyError { + switch self { + case .codex: + return .missingCodexAuth(authIndex) + case .gemini, .antigravity: + return .missingProviderAuth(provider: self.displayName, authIndex: authIndex) + } + } +} + public struct CodexCLIProxyManagementClient: Sendable { private let settings: CodexCLIProxySettings private let session: URLSession + private static let geminiQuotaURL = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" + private static let geminiLoadCodeAssistURL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + private static let geminiFallbackProjectID = "just-well-nxk81" + private static let geminiHeaders = [ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", + ] public init(settings: CodexCLIProxySettings, session: URLSession = .shared) { self.settings = settings @@ -80,30 +168,93 @@ public struct CodexCLIProxyManagementClient: Sendable { } public func resolveCodexAuth() async throws -> CodexCLIProxyResolvedAuth { - let auths = try await self.listCodexAuths() + try await self.resolveAuth(for: .codex) + } + + public func listCodexAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .codex) + } + + public func resolveGeminiAuth() async throws -> CodexCLIProxyResolvedAuth { + try await self.resolveAuth(for: .gemini) + } + + public func listGeminiAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .gemini) + } + + public func resolveAntigravityAuth() async throws -> CodexCLIProxyResolvedAuth { + try await self.resolveAuth(for: .antigravity) + } + + public func listAntigravityAuths() async throws -> [CodexCLIProxyResolvedAuth] { + try await self.listAuths(for: .antigravity) + } + + public func fetchGeminiQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + try await self.fetchGeminiLikeQuota(auth: auth) + } + + public func fetchAntigravityQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + try await self.fetchGeminiLikeQuota(auth: auth) + } + + public func fetchCodexUsage(auth: CodexCLIProxyResolvedAuth) async throws -> CodexUsageResponse { + let usageURL = "https://chatgpt.com/backend-api/wham/usage" + var headers = [ + "Authorization": "Bearer $TOKEN$", + "Accept": "application/json", + "User-Agent": "CodexBar", + ] + if let accountID = auth.chatGPTAccountID, !accountID.isEmpty { + headers["ChatGPT-Account-Id"] = accountID + } + + let body = APICallRequest( + authIndex: auth.authIndex, + method: "GET", + url: usageURL, + header: headers, + data: nil) + let callResponse = try await self.post(path: "/api-call", body: body) + + let statusCode = callResponse.statusCode + guard (200...299).contains(statusCode) else { + throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody) + } + + guard let bodyString = callResponse.body else { + throw CodexCLIProxyError.invalidResponse + } + let payload = Data(bodyString.utf8) + do { + return try JSONDecoder().decode(CodexUsageResponse.self, from: payload) + } catch { + throw CodexCLIProxyError.decodeFailed(error.localizedDescription) + } + } + + private func resolveAuth(for provider: CLIProxyAuthProvider) async throws -> CodexCLIProxyResolvedAuth { + let auths = try await self.listAuths(for: provider) if let preferred = self.settings.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines), !preferred.isEmpty { guard let selected = auths.first(where: { $0.authIndex == preferred }) else { - throw CodexCLIProxyError.missingCodexAuth(preferred) + throw provider.missingAuthError(authIndex: preferred) } return selected } guard let selected = auths.first else { - throw CodexCLIProxyError.missingCodexAuth(nil) + throw provider.missingAuthError(authIndex: nil) } return selected } - public func listCodexAuths() async throws -> [CodexCLIProxyResolvedAuth] { + private func listAuths(for provider: CLIProxyAuthProvider) async throws -> [CodexCLIProxyResolvedAuth] { let response = try await self.fetchAuthFiles() - let auths = response.files.filter { file in - let provider = file.provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let type = file.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return provider == "codex" || type == "codex" - } + let auths = response.files.filter { provider.matches(provider: $0.provider, type: $0.type) } let enabledAuths = auths.filter { !($0.disabled ?? false) } let pool = enabledAuths.isEmpty ? auths : enabledAuths @@ -119,41 +270,107 @@ public struct CodexCLIProxyManagementClient: Sendable { } } - public func fetchCodexUsage(auth: CodexCLIProxyResolvedAuth) async throws -> CodexUsageResponse { - let usageURL = "https://chatgpt.com/backend-api/wham/usage" - var headers = [ - "Authorization": "Bearer $TOKEN$", - "Accept": "application/json", - "User-Agent": "CodexBar", - ] - if let accountID = auth.chatGPTAccountID, !accountID.isEmpty { - headers["ChatGPT-Account-Id"] = accountID + private func fetchGeminiLikeQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse { + let projectID = await self.resolveGeminiProjectID(auth: auth) ?? Self.geminiFallbackProjectID + let payload = try await self.fetchGeminiLikeQuota(auth: auth, projectID: projectID) + if !payload.buckets.isEmpty { return payload } + if projectID != Self.geminiFallbackProjectID { + return try await self.fetchGeminiLikeQuota(auth: auth, projectID: Self.geminiFallbackProjectID) + } + return payload + } + + private func fetchGeminiLikeQuota( + auth: CodexCLIProxyResolvedAuth, + projectID: String) async throws -> CLIProxyGeminiQuotaResponse + { + let bodyPayload = GeminiQuotaRequestPayload(project: projectID) + let requestData = try JSONEncoder().encode(bodyPayload) + guard let requestString = String(data: requestData, encoding: .utf8) else { + throw CodexCLIProxyError.invalidResponse } let body = APICallRequest( authIndex: auth.authIndex, - method: "GET", - url: usageURL, - header: headers, - data: nil) + method: "POST", + url: Self.geminiQuotaURL, + header: Self.geminiHeaders, + data: requestString) let callResponse = try await self.post(path: "/api-call", body: body) - let statusCode = callResponse.statusCode guard (200...299).contains(statusCode) else { throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody) } - guard let bodyString = callResponse.body else { throw CodexCLIProxyError.invalidResponse } - let payload = Data(bodyString.utf8) + + let responseData = Data(bodyString.utf8) do { - return try JSONDecoder().decode(CodexUsageResponse.self, from: payload) + let decoded = try JSONDecoder().decode(GeminiQuotaResponsePayload.self, from: responseData) + let buckets = decoded.buckets.compactMap { bucket -> CLIProxyGeminiQuotaBucket? in + guard let modelID = bucket.modelID?.trimmingCharacters(in: .whitespacesAndNewlines), + !modelID.isEmpty, + let remainingFraction = bucket.remainingFraction + else { + return nil + } + return CLIProxyGeminiQuotaBucket( + modelID: modelID, + remainingFraction: remainingFraction, + resetTime: self.parseGeminiResetDate(bucket.resetTime)) + } + return CLIProxyGeminiQuotaResponse(buckets: buckets) } catch { throw CodexCLIProxyError.decodeFailed(error.localizedDescription) } } + private func resolveGeminiProjectID(auth: CodexCLIProxyResolvedAuth) async -> String? { + let body = APICallRequest( + authIndex: auth.authIndex, + method: "POST", + url: Self.geminiLoadCodeAssistURL, + header: Self.geminiHeaders, + data: "{}") + + guard let response = try? await self.post(path: "/api-call", body: body), + (200 ... 299).contains(response.statusCode), + let bodyString = response.body, + let data = bodyString.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + if let project = raw["cloudaicompanionProject"] as? String { + let normalized = project.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + if let project = raw["cloudaicompanionProject"] as? [String: Any] { + if let id = project["id"] as? String { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { return normalized } + } + if let projectID = project["projectId"] as? String { + let normalized = projectID.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { return normalized } + } + } + + return nil + } + + private func parseGeminiResetDate(_ raw: String?) -> Date? { + guard let raw else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: raw) + } + private func fetchAuthFiles() async throws -> AuthFilesResponse { let (data, statusCode) = try await self.get(path: "/auth-files") guard (200...299).contains(statusCode) else { @@ -304,3 +521,25 @@ private struct APICallResponse: Decodable { return trimmed.count > 320 ? String(trimmed.prefix(320)) + "…" : trimmed } } + +private struct GeminiQuotaRequestPayload: Encodable { + let project: String +} + +private struct GeminiQuotaResponsePayload: Decodable { + let buckets: [GeminiQuotaBucketPayload] +} + +private struct GeminiQuotaBucketPayload: Decodable { + let remainingFraction: Double? + let resetTime: String? + let modelID: String? + let tokenType: String? + + enum CodingKeys: String, CodingKey { + case remainingFraction + case resetTime + case modelID = "modelId" + case tokenType + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift new file mode 100644 index 000000000..45cb2248c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum CLIProxyGeminiQuotaSnapshotMapper { + public static func usageSnapshot( + from response: CLIProxyGeminiQuotaResponse, + auth: CodexCLIProxyResolvedAuth, + provider: UsageProvider) -> UsageSnapshot + { + let modelBuckets = self.reduceByModel(response.buckets) + let proBucket = self.lowestBucket(matching: "pro", from: modelBuckets) + let flashBucket = self.lowestBucket(matching: "flash", from: modelBuckets) + let fallbackBucket = modelBuckets.min(by: { $0.remainingFraction < $1.remainingFraction }) + + let primary = self.makeWindow(proBucket ?? fallbackBucket) + ?? RateWindow(usedPercent: 0, windowMinutes: 1440, resetsAt: nil, resetDescription: nil) + let secondary = self.makeWindow(flashBucket) + + let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + updatedAt: Date(), + identity: identity) + .scoped(to: provider) + } + + private static func reduceByModel(_ buckets: [CLIProxyGeminiQuotaBucket]) -> [CLIProxyGeminiQuotaBucket] { + var byModel: [String: CLIProxyGeminiQuotaBucket] = [:] + for bucket in buckets { + guard !bucket.modelID.isEmpty else { continue } + if let existing = byModel[bucket.modelID], existing.remainingFraction <= bucket.remainingFraction { + continue + } + byModel[bucket.modelID] = bucket + } + return byModel.values.sorted { $0.modelID < $1.modelID } + } + + private static func lowestBucket( + matching token: String, + from buckets: [CLIProxyGeminiQuotaBucket]) -> CLIProxyGeminiQuotaBucket? + { + buckets + .filter { $0.modelID.localizedCaseInsensitiveContains(token) } + .min(by: { $0.remainingFraction < $1.remainingFraction }) + } + + private static func makeWindow(_ bucket: CLIProxyGeminiQuotaBucket?) -> RateWindow? { + guard let bucket else { return nil } + let usedPercent = max(0, min(100, (1 - bucket.remainingFraction) * 100)) + let resetDescription = bucket.resetTime.map { UsageFormatter.resetDescription(from: $0) } + return RateWindow( + usedPercent: usedPercent, + windowMinutes: 1440, + resetsAt: bucket.resetTime, + resetDescription: resetDescription) + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift new file mode 100644 index 000000000..e5ca42c8a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift @@ -0,0 +1,77 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum GeminiProxyProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .geminiproxy, + metadata: ProviderMetadata( + id: .geminiproxy, + displayName: "CLIProxy Gemini", + sessionLabel: "Pro", + weeklyLabel: "Flash", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CLIProxy Gemini usage", + cliName: "gemini-proxy", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "http://127.0.0.1:8317/management.html#/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .gemini, + iconResourceName: "ProviderIcon-gemini", + color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Gemini cost summary is not supported for CLIProxy source." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [GeminiCLIProxyFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "gemini-proxy", + aliases: ["cliproxy-gemini"], + versionDetector: nil)) + } +} + +private struct GeminiCLIProxyFetchStrategy: ProviderFetchStrategy { + let id: String = "geminiproxy.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let settings = CodexCLIProxySettings.resolve( + providerSettings: context.settings?.codex, + environment: context.env) + else { + throw CodexCLIProxyError.missingManagementKey + } + + let client = CodexCLIProxyManagementClient(settings: settings) + let auth = try await client.resolveGeminiAuth() + let quota = try await client.fetchGeminiQuota(auth: auth) + let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot( + from: quota, + auth: auth, + provider: .geminiproxy) + + return self.makeResult( + usage: snapshot, + sourceLabel: "cliproxy-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index f8ce6085a..09d01e8fc 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -55,6 +55,8 @@ public enum ProviderDescriptorRegistry { private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, .codexproxy: CodexProxyProviderDescriptor.descriptor, + .geminiproxy: GeminiProxyProviderDescriptor.descriptor, + .antigravityproxy: AntigravityProxyProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 9f70c78ce..af4693a4a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -5,6 +5,8 @@ import SweetCookieKit public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex case codexproxy + case geminiproxy + case antigravityproxy case claude case cursor case opencode diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings index 0036cbd58..265049425 100644 --- a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -44,6 +44,8 @@ "error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI management API failed (%d): %@"; "error.codex.cliproxy.missing_auth" = "CLIProxyAPI has no available Codex auth entry."; "error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI did not find Codex auth_index %@."; +"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI has no available %@ auth entry."; +"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI did not find %@ auth_index %@."; "error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call failed (%d)."; "error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call failed (%d): %@"; "error.codex.cliproxy.decode_failed" = "Failed to decode CLIProxyAPI response: %@"; @@ -75,6 +77,7 @@ "menu.action.credits_history" = "Credits history"; "menu.action.usage_breakdown" = "Usage breakdown"; "menu.codex.cliproxy.auth_grid.title" = "Codex auth entries (%d)"; +"menu.cliproxy.auth_grid.title" = "%@ auth entries (%d)"; "menu.card.percent.left" = "left"; "menu.card.percent.used" = "used"; @@ -109,10 +112,11 @@ "settings.general.cliproxy.key.title" = "Management Key"; "settings.general.cliproxy.key.placeholder" = "Paste management key…"; "settings.general.cliproxy.auth_index.title" = "auth_index (optional)"; -"settings.general.cliproxy.auth_index.subtitle" = "Optional. Set a specific auth file; leave empty to aggregate all Codex auth entries."; -"settings.general.cliproxy.auth_index.placeholder" = "Leave empty to load all available Codex auth entries"; +"settings.general.cliproxy.auth_index.subtitle" = "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries."; +"settings.general.cliproxy.auth_index.placeholder" = "Leave empty to load all matching auth entries"; "provider.codex.cliproxy.aggregate.account_label" = "All Codex auth entries (%d)"; +"provider.cliproxy.aggregate.account_label" = "All %@ auth entries (%d)"; "settings.general.system.section" = "System"; "settings.general.system.start_at_login.title" = "Start at Login"; diff --git a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings index 7bffbf1ad..f6c9455ad 100644 --- a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings @@ -44,6 +44,8 @@ "error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI 管理接口请求失败(%d):%@"; "error.codex.cliproxy.missing_auth" = "CLIProxyAPI 中没有可用的 Codex 认证条目。"; "error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI 未找到 Codex auth_index %@。"; +"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI 中没有可用的 %@ 认证条目。"; +"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI 未找到 %@ auth_index %@。"; "error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call 请求失败(%d)。"; "error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call 请求失败(%d):%@"; "error.codex.cliproxy.decode_failed" = "解析 CLIProxyAPI 响应失败:%@"; @@ -75,6 +77,7 @@ "menu.action.credits_history" = "积分历史"; "menu.action.usage_breakdown" = "用量拆分"; "menu.codex.cliproxy.auth_grid.title" = "Codex 认证条目(%d 个)"; +"menu.cliproxy.auth_grid.title" = "%@ 认证条目(%d 个)"; "menu.card.percent.left" = "剩余"; "menu.card.percent.used" = "已用"; @@ -109,10 +112,11 @@ "settings.general.cliproxy.key.title" = "管理密钥"; "settings.general.cliproxy.key.placeholder" = "粘贴 management key…"; "settings.general.cliproxy.auth_index.title" = "auth_index(可选)"; -"settings.general.cliproxy.auth_index.subtitle" = "可选。指定某个认证文件;留空则聚合所有 Codex 认证条目。"; -"settings.general.cliproxy.auth_index.placeholder" = "留空将加载全部可用 Codex 认证条目"; +"settings.general.cliproxy.auth_index.subtitle" = "可选。指定某个认证文件;留空则聚合所有匹配的认证条目。"; +"settings.general.cliproxy.auth_index.placeholder" = "留空将加载全部匹配的认证条目"; "provider.codex.cliproxy.aggregate.account_label" = "全部 Codex 认证条目(%d 个)"; +"provider.cliproxy.aggregate.account_label" = "全部 %@ 认证条目(%d 个)"; "settings.general.system.section" = "系统"; "settings.general.system.start_at_login.title" = "开机启动"; diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 982df9851..d9c22a30c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -63,6 +63,10 @@ enum CostUsageScanner { return self.loadCodexDaily(range: range, now: now, options: options) case .codexproxy: return self.loadCodexDaily(range: range, now: now, options: options) + case .geminiproxy: + return CostUsageDailyReport(data: [], summary: nil) + case .antigravityproxy: + return CostUsageDailyReport(data: [], summary: nil) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) case .zai: diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index bb73c6423..d7763a977 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -43,6 +43,8 @@ enum ProviderChoice: String, AppEnum { switch provider { case .codex: self = .codex case .codexproxy: return nil // CLIProxy Codex not yet supported in widgets + case .geminiproxy: return nil // CLIProxy Gemini not yet supported in widgets + case .antigravityproxy: return nil // CLIProxy Antigravity not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini case .antigravity: self = .antigravity diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 1b5b9e09a..d298e746d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -259,6 +259,8 @@ private struct ProviderSwitchChip: View { switch self.provider { case .codex: "Codex" case .codexproxy: "CdxProxy" + case .geminiproxy: "GemProxy" + case .antigravityproxy: "AntiProxy" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" @@ -574,6 +576,10 @@ enum WidgetColors { Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) case .codexproxy: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .geminiproxy: + Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) + case .antigravityproxy: + Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: From ac16f646beabc41d3d3ccaabdd92092164d81ebd Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 04:42:42 +0800 Subject: [PATCH 4/8] Add icon size adjustment and improve button tooltip handling in ProviderSwitcherView - Introduced `setIconSize` method in `InlineIconToggleButton` and `StackedToggleButton` to allow dynamic icon size adjustments. - Updated `ProviderSwitcherView` to calculate and set icon sizes based on layout conditions. - Enhanced tooltip handling for buttons to display provider-specific names instead of default values. - Improved image scaling for buttons to ensure better visual consistency. --- .../CodexBar/ProviderSwitcherButtons.swift | 18 ++++++++-- .../StatusItemController+SwitcherViews.swift | 35 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift index 05ce53c53..6fd47f71d 100644 --- a/Sources/CodexBar/ProviderSwitcherButtons.swift +++ b/Sources/CodexBar/ProviderSwitcherButtons.swift @@ -66,6 +66,13 @@ final class InlineIconToggleButton: NSButton { self.titleField.textColor = color } + func setIconSize(_ size: CGFloat) { + guard self.iconSizeConstraints.count == 2 else { return } + self.iconSizeConstraints[0].constant = size + self.iconSizeConstraints[1].constant = size + if !self.isConfiguring { self.invalidateIntrinsicContentSize() } + } + func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } @@ -109,7 +116,7 @@ final class InlineIconToggleButton: NSButton { self.controlSize = .small self.wantsLayer = true - self.iconView.imageScaling = .scaleNone + self.iconView.imageScaling = .scaleProportionallyUpOrDown self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) self.titleField.alignment = .left @@ -195,6 +202,13 @@ final class StackedToggleButton: NSButton { self.titleField.textColor = color } + func setIconSize(_ size: CGFloat) { + guard self.iconSizeConstraints.count == 2 else { return } + self.iconSizeConstraints[0].constant = size + self.iconSizeConstraints[1].constant = size + if !self.isConfiguring { self.invalidateIntrinsicContentSize() } + } + func setTitleFontSize(_ size: CGFloat) { self.titleField.font = NSFont.systemFont(ofSize: size) } @@ -238,7 +252,7 @@ final class StackedToggleButton: NSButton { self.controlSize = .small self.wantsLayer = true - self.iconView.imageScaling = .scaleNone + self.iconView.imageScaling = .scaleProportionallyUpOrDown self.iconView.translatesAutoresizingMaskIntoConstraints = false self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 2) self.titleField.alignment = .center diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 98dcb693d..4d64affeb 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -100,6 +100,11 @@ final class ProviderSwitcherView: NSView { count: layoutCount, outerPadding: outerPadding, minimumGap: minimumGap) + let iconPointSize = Self.switcherIconPointSize( + stackedIcons: self.stackedIcons, + rowCount: self.rowCount, + segmentCount: self.segments.count, + maxAllowedSegmentWidth: maxAllowedSegmentWidth) func makeButton(index: Int, segment: Segment) -> NSButton { let button: NSButton @@ -113,6 +118,7 @@ final class ProviderSwitcherView: NSView { if self.rowCount >= 4 { stacked.setTitleFontSize(NSFont.smallSystemFontSize - 3) } + stacked.setIconSize(iconPointSize) button = stacked } else if self.showsIcons { let inline = InlineIconToggleButton( @@ -120,6 +126,7 @@ final class ProviderSwitcherView: NSView { image: segment.image, target: self, action: #selector(self.handleSelection(_:))) + inline.setIconSize(iconPointSize) button = inline } else { button = PaddedToggleButton( @@ -151,7 +158,7 @@ final class ProviderSwitcherView: NSView { button.wantsLayer = true button.layer?.cornerRadius = 6 button.state = (selected == segment.provider) ? .on : .off - button.toolTip = nil + button.toolTip = ProviderDescriptorRegistry.descriptor(for: segment.provider).metadata.displayName button.translatesAutoresizingMaskIntoConstraints = false self.buttons.append(button) return button @@ -457,6 +464,21 @@ final class ProviderSwitcherView: NSView { return rows } + private static func switcherIconPointSize( + stackedIcons: Bool, + rowCount: Int, + segmentCount: Int, + maxAllowedSegmentWidth: CGFloat) -> CGFloat + { + if !stackedIcons { + return maxAllowedSegmentWidth < 72 ? 14 : 16 + } + if rowCount >= 4 || maxAllowedSegmentWidth < 44 { return 11 } + if rowCount >= 3 || maxAllowedSegmentWidth < 50 { return 12 } + if segmentCount >= 7 || maxAllowedSegmentWidth < 58 { return 13 } + return 14 + } + private static func switcherOuterPadding(for width: CGFloat, count: Int, minimumGap: CGFloat) -> CGFloat { // Align with the card's left/right content grid when possible. let preferred: CGFloat = 16 @@ -754,7 +776,16 @@ final class ProviderSwitcherView: NSView { } private static func switcherTitle(for provider: UsageProvider) -> String { - ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + switch provider { + case .codexproxy: + "CodexProxy" + case .geminiproxy: + "GemProxy" + case .antigravityproxy: + "AGProxy" + default: + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } } } From 700d70be8a64833be6f96ba757da491d7c728452 Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 05:19:50 +0800 Subject: [PATCH 5/8] Enhance localization and UI consistency across preferences panes - Integrated localization support for various UI elements in the PreferencesAboutPane, PreferencesAdvancedPane, PreferencesDisplayPane, and others. - Updated text fields, buttons, and labels to utilize localized strings for improved internationalization. - Improved user experience by ensuring consistent terminology and phrasing throughout the settings interface. - Added missing localization keys and refined existing ones to enhance clarity and usability. --- Sources/CodexBar/PreferencesAboutPane.swift | 47 ++++++-- .../CodexBar/PreferencesAdvancedPane.swift | 75 ++++++++---- Sources/CodexBar/PreferencesDisplayPane.swift | 78 ++++++++---- .../PreferencesProviderDetailView.swift | 93 +++++++++----- .../PreferencesProviderErrorView.swift | 10 +- .../PreferencesProviderSettingsRows.swift | 18 ++- .../PreferencesProviderSidebarView.swift | 9 +- .../CodexBar/PreferencesProvidersPane.swift | 10 +- Sources/CodexBar/PreferencesView.swift | 37 +++++- Sources/CodexBar/SettingsStore+Defaults.swift | 6 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBarCore/Localization.swift | 71 ++++++++--- .../Resources/en.lproj/Localizable.strings | 103 ++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 113 +++++++++++++++++- 14 files changed, 546 insertions(+), 127 deletions(-) diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 16e27189e..d4a962dc4 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI @MainActor @@ -51,14 +52,22 @@ struct AboutPane: View { VStack(spacing: 2) { Text("CodexBar") .font(.title3).bold() - Text("Version \(self.versionString)") + Text(String( + format: L10n.tr("settings.about.version", fallback: "Version %@"), + locale: .current, + self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(String( + format: L10n.tr("settings.about.build", fallback: "Built %@"), + locale: .current, + buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(L10n.tr( + "settings.about.tagline", + fallback: "May your tokens never run out—keep agent limits in view.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -66,11 +75,20 @@ struct AboutPane: View { VStack(alignment: .center, spacing: 10) { AboutLinkRow( icon: "chevron.left.slash.chevron.right", - title: "GitHub", + title: L10n.tr("settings.about.link.github", fallback: "GitHub"), url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") - AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") + AboutLinkRow( + icon: "globe", + title: L10n.tr("settings.about.link.website", fallback: "Website"), + url: "https://steipete.me") + AboutLinkRow( + icon: "bird", + title: L10n.tr("settings.about.link.twitter", fallback: "Twitter"), + url: "https://twitter.com/steipete") + AboutLinkRow( + icon: "envelope", + title: L10n.tr("settings.about.link.email", fallback: "Email"), + url: "mailto:peter@steipete.me") } .padding(.top, 8) .frame(maxWidth: .infinity) @@ -80,12 +98,14 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle( + L10n.tr("settings.about.updates.auto_check", fallback: "Check for updates automatically"), + isOn: self.$autoUpdateEnabled) .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .center) VStack(spacing: 6) { HStack(spacing: 12) { - Text("Update Channel") + Text(L10n.tr("settings.about.updates.channel", fallback: "Update Channel")) Spacer() Picker("", selection: self.updateChannelBinding) { ForEach(UpdateChannel.allCases) { channel in @@ -102,14 +122,17 @@ struct AboutPane: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) } - Button("Check for Updates…") { self.updater.checkForUpdates(nil) } + Button(L10n.tr("settings.about.updates.check_now", fallback: "Check for Updates…")) { + self.updater.checkForUpdates(nil) + } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? + L10n.tr("settings.about.updates.unavailable", fallback: "Updates unavailable in this build.")) .foregroundStyle(.secondary) } - Text("© 2025 Peter Steinberger. MIT License.") + Text(L10n.tr("settings.about.copyright", fallback: "© 2025 Peter Steinberger. MIT License.")) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 4) diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..2e704068f 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -1,3 +1,4 @@ +import CodexBarCore import KeyboardShortcuts import SwiftUI @@ -11,17 +12,19 @@ struct AdvancedPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { - Text("Keyboard shortcut") + Text(L10n.tr("settings.advanced.keyboard.section", fallback: "Keyboard shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(L10n.tr("settings.advanced.keyboard.open_menu.title", fallback: "Open menu")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(L10n.tr( + "settings.advanced.keyboard.open_menu.subtitle", + fallback: "Trigger the menu bar menu from anywhere.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -36,7 +39,7 @@ struct AdvancedPane: View { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { - Text("Install CLI") + Text(L10n.tr("settings.advanced.cli.install", fallback: "Install CLI")) } } .disabled(self.isInstallingCLI) @@ -48,7 +51,9 @@ struct AdvancedPane: View { .lineLimit(2) } } - Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") + Text(L10n.tr( + "settings.advanced.cli.install.subtitle", + fallback: "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -57,12 +62,16 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Show Debug Settings", - subtitle: "Expose troubleshooting tools in the Debug tab.", + title: L10n.tr("settings.advanced.debug.title", fallback: "Show Debug Settings"), + subtitle: L10n.tr( + "settings.advanced.debug.subtitle", + fallback: "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.", + title: L10n.tr("settings.advanced.surprise.title", fallback: "Surprise me"), + subtitle: L10n.tr( + "settings.advanced.surprise.subtitle", + fallback: "Check if you like your agents having some fun up there."), binding: self.$settings.randomBlinkEnabled) } @@ -70,22 +79,33 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Hide personal information", - subtitle: "Obscure email addresses in the menu bar and menu UI.", + title: L10n.tr( + "settings.advanced.privacy.hide_personal_info.title", + fallback: "Hide personal information"), + subtitle: L10n.tr( + "settings.advanced.privacy.hide_personal_info.subtitle", + fallback: "Obscure email addresses in the menu bar and menu UI."), binding: self.$settings.hidePersonalInfo) } Divider() SettingsSection( - title: "Keychain access", - caption: """ + title: L10n.tr("settings.advanced.keychain.title", fallback: "Keychain access"), + caption: L10n.tr( + "settings.advanced.keychain.caption", + fallback: """ 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.", + title: L10n.tr( + "settings.advanced.keychain.disable.title", + fallback: "Disable Keychain access"), + subtitle: L10n.tr( + "settings.advanced.keychain.disable.subtitle", + fallback: "Prevents any Keychain access while enabled."), binding: self.$settings.debugDisableKeychainAccess) } } @@ -105,7 +125,9 @@ extension AdvancedPane { 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." + self.cliStatus = L10n.tr( + "settings.advanced.cli.status.helper_not_found", + fallback: "CodexBarCLI not found in app bundle.") return } @@ -119,29 +141,36 @@ extension AdvancedPane { 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)") + let format = L10n.tr( + "settings.advanced.cli.status.no_write_access", + fallback: "No write access: %@") + results.append(String(format: format, locale: .current, dir)) continue } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@") + results.append(String(format: format, locale: .current, dir)) } else { - results.append("Exists: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.exists", fallback: "Exists: %@") + results.append(String(format: format, locale: .current, dir)) } continue } do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@") + results.append(String(format: format, locale: .current, dir)) } catch { - results.append("Failed: \(dir)") + let format = L10n.tr("settings.advanced.cli.status.failed", fallback: "Failed: %@") + results.append(String(format: format, locale: .current, dir)) } } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? L10n.tr("settings.advanced.cli.status.no_writable_dirs", fallback: "No writable bin dirs found.") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27ee..925f395b8 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI @MainActor @@ -8,40 +9,59 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + Text(L10n.tr("settings.display.menu_bar.section", fallback: "Menu bar")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Merge Icons", - subtitle: "Use a single menu bar icon with a provider switcher.", + title: L10n.tr("settings.display.menu_bar.merge_icons.title", fallback: "Merge Icons"), + subtitle: L10n.tr( + "settings.display.menu_bar.merge_icons.subtitle", + fallback: "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).", + title: L10n.tr( + "settings.display.menu_bar.switcher_icons.title", + fallback: "Switcher shows icons"), + subtitle: L10n.tr( + "settings.display.menu_bar.switcher_icons.subtitle", + fallback: "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.", + title: L10n.tr( + "settings.display.menu_bar.highest_usage.title", + fallback: "Show most-used provider"), + subtitle: L10n.tr( + "settings.display.menu_bar.highest_usage.subtitle", + fallback: "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.", + title: L10n.tr( + "settings.display.menu_bar.brand_percent.title", + fallback: "Menu bar shows percent"), + subtitle: L10n.tr( + "settings.display.menu_bar.brand_percent.subtitle", + fallback: "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") + Text(L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode")) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + Text(L10n.tr( + "settings.display.menu_bar.mode.subtitle", + fallback: "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) { + Picker( + L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode"), + selection: self.$settings.menuBarDisplayMode) + { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -57,25 +77,41 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + Text(L10n.tr("settings.display.menu_content.section", fallback: "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).", + title: L10n.tr( + "settings.display.menu_content.usage_as_used.title", + fallback: "Show usage as used"), + subtitle: L10n.tr( + "settings.display.menu_content.usage_as_used.subtitle", + fallback: "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.", + title: L10n.tr( + "settings.display.menu_content.reset_clock.title", + fallback: "Show reset time as clock"), + subtitle: L10n.tr( + "settings.display.menu_content.reset_clock.subtitle", + fallback: "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.", + title: L10n.tr( + "settings.display.menu_content.optional_usage.title", + fallback: "Show credits + extra usage"), + subtitle: L10n.tr( + "settings.display.menu_content.optional_usage.subtitle", + fallback: "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).", + title: L10n.tr( + "settings.display.menu_content.all_token_accounts.title", + fallback: "Show all token accounts"), + subtitle: L10n.tr( + "settings.display.menu_content.all_token_accounts.subtitle", + fallback: "Stack token accounts in the menu (otherwise show an account switcher bar)."), binding: self.$settings.showAllTokenAccountsInMenu) } } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f8b843240..ad89f0891 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -38,14 +38,19 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: String( + format: L10n.tr( + "settings.providers.error.last_fetch_failed", + fallback: "Last %@ fetch failed:"), + locale: .current, + self.store.metadata(for: self.provider).displayName), display: errorDisplay, isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) } if self.hasSettings { - ProviderSettingsSection(title: "Settings") { + ProviderSettingsSection(title: L10n.tr("settings.providers.section.settings", fallback: "Settings")) { ForEach(self.settingsPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } @@ -61,7 +66,7 @@ struct ProviderDetailView: View { } if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: "Options") { + ProviderSettingsSection(title: L10n.tr("settings.providers.section.options", fallback: "Options")) { ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } @@ -82,26 +87,31 @@ struct ProviderDetailView: View { } private var detailLabelWidth: CGFloat { - var infoLabels = ["State", "Source", "Version", "Updated"] + var infoLabels = [ + L10n.tr("settings.providers.detail.label.state", fallback: "State"), + L10n.tr("settings.providers.detail.label.source", fallback: "Source"), + L10n.tr("settings.providers.detail.label.version", fallback: "Version"), + L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"), + ] if self.store.status(for: self.provider) != nil { - infoLabels.append("Status") + infoLabels.append(L10n.tr("settings.providers.detail.label.status", fallback: "Status")) } if !self.model.email.isEmpty { - infoLabels.append("Account") + infoLabels.append(L10n.tr("settings.providers.detail.label.account", fallback: "Account")) } if let plan = self.model.planText, !plan.isEmpty { - infoLabels.append("Plan") + infoLabels.append(L10n.tr("settings.providers.detail.label.plan", fallback: "Plan")) } var metricLabels = self.model.metrics.map(\.title) if self.model.creditsText != nil { - metricLabels.append("Credits") + metricLabels.append(L10n.tr("settings.providers.detail.label.credits", fallback: "Credits")) } if let providerCost = self.model.providerCost { metricLabels.append(providerCost.title) } if self.model.tokenUsage != nil { - metricLabels.append("Cost") + metricLabels.append(L10n.tr("settings.providers.detail.label.cost", fallback: "Cost")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -147,7 +157,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(L10n.tr("settings.providers.detail.help.refresh", fallback: "Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -207,31 +217,52 @@ private struct ProviderDetailInfoGrid: View { 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 version = self.store.version(for: self.provider) + ?? L10n.tr("settings.providers.detail.version.not_detected", fallback: "not detected") let updated = self.updatedText let email = self.model.email let plan = self.model.planText ?? "" - let enabledText = self.isEnabled ? "Enabled" : "Disabled" + let enabledText = self.isEnabled + ? L10n.tr("settings.providers.detail.state.enabled", fallback: "Enabled") + : L10n.tr("settings.providers.detail.state.disabled", fallback: "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) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.state", fallback: "State"), + value: enabledText, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.source", fallback: "Source"), + value: source, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.version", fallback: "Version"), + value: version, + labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"), + value: updated, + labelWidth: self.labelWidth) if let status { ProviderDetailInfoRow( - label: "Status", + label: L10n.tr("settings.providers.detail.label.status", fallback: "Status"), value: status.description ?? status.indicator.label, labelWidth: self.labelWidth) } if !email.isEmpty { - ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.account", fallback: "Account"), + value: email, + labelWidth: self.labelWidth) } if !plan.isEmpty { - ProviderDetailInfoRow(label: "Plan", value: plan, labelWidth: self.labelWidth) + ProviderDetailInfoRow( + label: L10n.tr("settings.providers.detail.label.plan", fallback: "Plan"), + value: plan, + labelWidth: self.labelWidth) } } .font(.footnote) @@ -243,9 +274,9 @@ private struct ProviderDetailInfoGrid: View { return UsageFormatter.updatedString(from: updated) } if self.store.refreshingProviders.contains(self.provider) { - return "Refreshing" + return L10n.tr("settings.providers.detail.updated.refreshing", fallback: "Refreshing") } - return "Not fetched yet" + return L10n.tr("settings.providers.detail.updated.not_fetched_yet", fallback: "Not fetched yet") } } @@ -273,7 +304,7 @@ struct ProviderMetricsInlineView: View { var body: some View { ProviderSettingsSection( - title: "Usage", + title: L10n.tr("settings.providers.section.usage", fallback: "Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -294,7 +325,7 @@ struct ProviderMetricsInlineView: View { if let credits = self.model.creditsText { ProviderMetricInlineTextRow( - title: "Credits", + title: L10n.tr("settings.providers.detail.label.credits", fallback: "Credits"), value: credits, labelWidth: self.labelWidth) } @@ -308,7 +339,7 @@ struct ProviderMetricsInlineView: View { if let tokenUsage = self.model.tokenUsage { ProviderMetricInlineTextRow( - title: "Cost", + title: L10n.tr("settings.providers.detail.label.cost", fallback: "Cost"), value: tokenUsage.sessionLine, labelWidth: self.labelWidth) ProviderMetricInlineTextRow( @@ -322,9 +353,12 @@ struct ProviderMetricsInlineView: View { private var placeholderText: String { if !self.isEnabled { - return "Disabled — no recent data" + return L10n.tr( + "settings.providers.metrics.placeholder.disabled_no_data", + fallback: "Disabled — no recent data") } - return self.model.placeholder ?? "No usage yet" + return self.model.placeholder ?? + L10n.tr("settings.providers.metrics.placeholder.no_usage", fallback: "No usage yet") } } @@ -433,11 +467,14 @@ private struct ProviderMetricInlineCostRow: View { UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, - accessibilityLabel: "Usage used") + accessibilityLabel: L10n.tr("settings.providers.cost.accessibility.usage_used", fallback: "Usage used")) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% used", self.section.percentUsed)) + Text(String( + format: L10n.tr("settings.providers.cost.percent_used", fallback: "%.0f%% used"), + locale: .current, + self.section.percentUsed)) .font(.footnote) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 55d45fcbb..613a975d2 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderErrorDisplay: Sendable { @@ -26,7 +27,7 @@ struct ProviderErrorView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Copy error") + .help(L10n.tr("settings.providers.error.copy", fallback: "Copy error")) } Text(self.display.preview) @@ -36,7 +37,12 @@ struct ProviderErrorView: View { .fixedSize(horizontal: false, vertical: true) if self.display.preview != self.display.full { - Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() } + Button(self.isExpanded + ? L10n.tr("settings.providers.error.hide_details", fallback: "Hide details") + : L10n.tr("settings.providers.error.show_details", fallback: "Show details")) + { + self.isExpanded.toggle() + } .buttonStyle(.link) .font(.footnote) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 5d7abde9a..d1bf61873 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI struct ProviderSettingsSection: View { @@ -218,7 +219,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(L10n.tr("settings.providers.token_accounts.empty", fallback: "No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -235,7 +236,10 @@ struct ProviderSettingsTokenAccountsRowView: View { .pickerStyle(.menu) .controlSize(.small) - Button("Remove selected account") { + Button(L10n.tr( + "settings.providers.token_accounts.remove_selected", + fallback: "Remove selected account")) + { let account = accounts[selectedIndex] self.descriptor.removeAccount(account.id) } @@ -244,13 +248,15 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) + TextField( + L10n.tr("settings.providers.token_accounts.label_placeholder", fallback: "Label"), + text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) SecureField(self.descriptor.placeholder, text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(L10n.tr("settings.providers.token_accounts.add", fallback: "Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } @@ -265,12 +271,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 10) { - Button("Open token file") { + Button(L10n.tr("settings.providers.token_accounts.open_token_file", fallback: "Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(L10n.tr("settings.providers.token_accounts.reload", fallback: "Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index ee34cb3e7..c23a202dc 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -62,7 +62,7 @@ private struct ProviderSidebarRowView: View { .contentShape(Rectangle()) .padding(.vertical, 4) .padding(.horizontal, 2) - .help("Drag to reorder") + .help(L10n.tr("settings.providers.sidebar.reorder.help", fallback: "Drag to reorder")) .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) @@ -106,12 +106,13 @@ private struct ProviderSidebarRowView: View { private var statusText: String { guard !self.isEnabled else { return self.subtitle } let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false) + let format = L10n.tr("settings.providers.sidebar.status.disabled_prefix", fallback: "Disabled — %@") if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") - return "Disabled — \(first)\n\(rest)" + return String(format: format, locale: .current, "\(first)\n\(rest)") } - return "Disabled — \(self.subtitle)" + return String(format: format, locale: .current, self.subtitle) } } @@ -135,7 +136,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(L10n.tr("settings.providers.sidebar.accessibility.reorder", fallback: "Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 378cbda4d..13bc8f53e 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -48,7 +48,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(L10n.tr("settings.providers.select_provider", fallback: "Select a provider")) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @@ -76,7 +76,9 @@ struct ProvidersPane: View { active.onConfirm() self.activeConfirmation = nil } - Button("Cancel", role: .cancel) { self.activeConfirmation = nil } + Button(L10n.tr("settings.providers.alert.cancel", fallback: "Cancel"), role: .cancel) { + self.activeConfirmation = nil + } } }, message: { @@ -113,9 +115,9 @@ struct ProvidersPane: View { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else if self.store.isStale(provider: provider) { - usageText = "last fetch failed" + usageText = L10n.tr("settings.providers.subtitle.last_fetch_failed", fallback: "last fetch failed") } else { - usageText = "usage not fetched yet" + usageText = L10n.tr("settings.providers.subtitle.not_fetched_yet", fallback: "usage not fetched yet") } let presentationContext = ProviderPresentationContext( diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 39413302c..2952419b9 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI enum PreferencesTab: String, Hashable { @@ -34,28 +35,52 @@ struct PreferencesView: View { var body: some View { TabView(selection: self.$selection.tab) { GeneralPane(settings: self.settings, store: self.store) - .tabItem { Label("General", systemImage: "gearshape") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.general", fallback: "General"), + systemImage: "gearshape") + } .tag(PreferencesTab.general) ProvidersPane(settings: self.settings, store: self.store) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.providers", fallback: "Providers"), + systemImage: "square.grid.2x2") + } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.display", fallback: "Display"), + systemImage: "eye") + } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.advanced", fallback: "Advanced"), + systemImage: "slider.horizontal.3") + } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.about", fallback: "About"), + systemImage: "info.circle") + } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) - .tabItem { Label("Debug", systemImage: "ladybug") } + .tabItem { + Label( + L10n.tr("settings.preferences.tab.debug", fallback: "Debug"), + systemImage: "ladybug") + } .tag(PreferencesTab.debug) } } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 6e98a7aec..d053df457 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -279,6 +279,12 @@ extension SettingsStore { set { self.defaultsState.appLanguageRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "appLanguageCode") + switch newValue { + case .system: + self.userDefaults.removeObject(forKey: "AppleLanguages") + case .english, .simplifiedChinese: + self.userDefaults.set([newValue.rawValue], forKey: "AppleLanguages") + } } } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 164ab4342..100b1aeda 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -170,6 +170,9 @@ final class SettingsStore { self.config = config self.configLoading = true self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + if AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) == .system { + self.userDefaults.removeObject(forKey: "AppleLanguages") + } self.updateProviderState(config: config) self.migrateLegacyCodexCLIProxyDefaultsIfNeeded() self.configLoading = false diff --git a/Sources/CodexBarCore/Localization.swift b/Sources/CodexBarCore/Localization.swift index 64926fc96..6d7d14a3a 100644 --- a/Sources/CodexBarCore/Localization.swift +++ b/Sources/CodexBarCore/Localization.swift @@ -2,9 +2,10 @@ import Foundation public enum L10n { private static let appLanguageKey = "appLanguageCode" + private static let appleLanguagesKey = "AppleLanguages" public static func tr(_ key: String, fallback: String) -> String { - let bundle = self.localizedBundleOverride() ?? .module + let bundle = self.localizedBundle() return NSLocalizedString( key, tableName: "Localizable", @@ -13,29 +14,67 @@ public enum L10n { comment: "") } - private static func localizedBundleOverride() -> Bundle? { - guard let raw = UserDefaults.standard.string(forKey: Self.appLanguageKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - else { - return nil + private static func localizedBundle() -> Bundle { + let selected = UserDefaults.standard.string(forKey: Self.appLanguageKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let usesSystemLanguage = selected.isEmpty || selected == "system" + + let preferences = usesSystemLanguage + ? self.systemLanguagePreferences() + : self.languageCandidates(for: selected) + + guard let bundle = self.bundle(matching: preferences) else { return .module } + return bundle + } + + private static func systemLanguagePreferences() -> [String] { + if let explicit = UserDefaults.standard.array(forKey: Self.appleLanguagesKey) as? [String], + !explicit.isEmpty + { + return explicit } - if raw == "system" { return nil } + let preferred = Locale.preferredLanguages + if !preferred.isEmpty { return preferred } + return [Locale.current.identifier] + } - let candidates = [ - raw, - raw.lowercased(), - raw.replacingOccurrences(of: "_", with: "-"), - raw.replacingOccurrences(of: "_", with: "-").lowercased(), - ] + private static func languageCandidates(for raw: String) -> [String] { + let normalized = raw.replacingOccurrences(of: "_", with: "-") + var candidates: [String] = [raw, raw.lowercased(), normalized, normalized.lowercased()] + if normalized.contains("-") { + let parts = normalized.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true) + if let base = parts.first { + candidates.append(String(base)) + candidates.append(String(base).lowercased()) + } + } - for candidate in candidates { - if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + var seen: Set = [] + return candidates.filter { seen.insert($0).inserted && !$0.isEmpty } + } + + private static func bundle(matching preferences: [String]) -> Bundle? { + let available = Bundle.module.localizations.filter { $0 != "Base" } + guard !available.isEmpty else { return nil } + + let preferred = Bundle.preferredLocalizations(from: available, forPreferences: preferences) + for language in preferred { + if let path = Bundle.module.path(forResource: language, ofType: "lproj"), let bundle = Bundle(path: path) { return bundle } } + + for language in preferences { + for candidate in self.languageCandidates(for: language) { + if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + } + } return nil } } diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings index 265049425..5d371dc1f 100644 --- a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings @@ -156,3 +156,106 @@ "settings.providers.menu_bar_metric.option.primary_with_label" = "Primary (%@)"; "settings.providers.menu_bar_metric.option.secondary_with_label" = "Secondary (%@)"; "settings.providers.menu_bar_metric.option.average_with_labels" = "Average (%@ + %@)"; + +"settings.preferences.tab.general" = "General"; +"settings.preferences.tab.providers" = "Providers"; +"settings.preferences.tab.display" = "Display"; +"settings.preferences.tab.advanced" = "Advanced"; +"settings.preferences.tab.about" = "About"; +"settings.preferences.tab.debug" = "Debug"; + +"settings.display.menu_bar.section" = "Menu bar"; +"settings.display.menu_bar.merge_icons.title" = "Merge Icons"; +"settings.display.menu_bar.merge_icons.subtitle" = "Use a single menu bar icon with a provider switcher."; +"settings.display.menu_bar.switcher_icons.title" = "Switcher shows icons"; +"settings.display.menu_bar.switcher_icons.subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"settings.display.menu_bar.highest_usage.title" = "Show most-used provider"; +"settings.display.menu_bar.highest_usage.subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; +"settings.display.menu_bar.brand_percent.title" = "Menu bar shows percent"; +"settings.display.menu_bar.brand_percent.subtitle" = "Replace critter bars with provider branding icons and a percentage."; +"settings.display.menu_bar.mode.title" = "Display mode"; +"settings.display.menu_bar.mode.subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"settings.display.menu_content.section" = "Menu content"; +"settings.display.menu_content.usage_as_used.title" = "Show usage as used"; +"settings.display.menu_content.usage_as_used.subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; +"settings.display.menu_content.reset_clock.title" = "Show reset time as clock"; +"settings.display.menu_content.reset_clock.subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"settings.display.menu_content.optional_usage.title" = "Show credits + extra usage"; +"settings.display.menu_content.optional_usage.subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; +"settings.display.menu_content.all_token_accounts.title" = "Show all token accounts"; +"settings.display.menu_content.all_token_accounts.subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; + +"settings.advanced.keyboard.section" = "Keyboard shortcut"; +"settings.advanced.keyboard.open_menu.title" = "Open menu"; +"settings.advanced.keyboard.open_menu.subtitle" = "Trigger the menu bar menu from anywhere."; +"settings.advanced.cli.install" = "Install CLI"; +"settings.advanced.cli.install.subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"settings.advanced.debug.title" = "Show Debug Settings"; +"settings.advanced.debug.subtitle" = "Expose troubleshooting tools in the Debug tab."; +"settings.advanced.surprise.title" = "Surprise me"; +"settings.advanced.surprise.subtitle" = "Check if you like your agents having some fun up there."; +"settings.advanced.privacy.hide_personal_info.title" = "Hide personal information"; +"settings.advanced.privacy.hide_personal_info.subtitle" = "Obscure email addresses in the menu bar and menu UI."; +"settings.advanced.keychain.title" = "Keychain access"; +"settings.advanced.keychain.caption" = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."; +"settings.advanced.keychain.disable.title" = "Disable Keychain access"; +"settings.advanced.keychain.disable.subtitle" = "Prevents any Keychain access while enabled."; +"settings.advanced.cli.status.helper_not_found" = "CodexBarCLI not found in app bundle."; +"settings.advanced.cli.status.no_write_access" = "No write access: %@"; +"settings.advanced.cli.status.installed" = "Installed: %@"; +"settings.advanced.cli.status.exists" = "Exists: %@"; +"settings.advanced.cli.status.failed" = "Failed: %@"; +"settings.advanced.cli.status.no_writable_dirs" = "No writable bin dirs found."; + +"settings.about.version" = "Version %@"; +"settings.about.build" = "Built %@"; +"settings.about.tagline" = "May your tokens never run out—keep agent limits in view."; +"settings.about.link.github" = "GitHub"; +"settings.about.link.website" = "Website"; +"settings.about.link.twitter" = "Twitter"; +"settings.about.link.email" = "Email"; +"settings.about.updates.auto_check" = "Check for updates automatically"; +"settings.about.updates.channel" = "Update Channel"; +"settings.about.updates.check_now" = "Check for Updates…"; +"settings.about.updates.unavailable" = "Updates unavailable in this build."; +"settings.about.copyright" = "© 2025 Peter Steinberger. MIT License."; + +"settings.providers.select_provider" = "Select a provider"; +"settings.providers.alert.cancel" = "Cancel"; +"settings.providers.subtitle.last_fetch_failed" = "last fetch failed"; +"settings.providers.subtitle.not_fetched_yet" = "usage not fetched yet"; +"settings.providers.error.last_fetch_failed" = "Last %@ fetch failed:"; +"settings.providers.error.copy" = "Copy error"; +"settings.providers.error.hide_details" = "Hide details"; +"settings.providers.error.show_details" = "Show details"; +"settings.providers.section.settings" = "Settings"; +"settings.providers.section.options" = "Options"; +"settings.providers.section.usage" = "Usage"; +"settings.providers.detail.label.state" = "State"; +"settings.providers.detail.label.source" = "Source"; +"settings.providers.detail.label.version" = "Version"; +"settings.providers.detail.label.updated" = "Updated"; +"settings.providers.detail.label.status" = "Status"; +"settings.providers.detail.label.account" = "Account"; +"settings.providers.detail.label.plan" = "Plan"; +"settings.providers.detail.label.credits" = "Credits"; +"settings.providers.detail.label.cost" = "Cost"; +"settings.providers.detail.help.refresh" = "Refresh"; +"settings.providers.detail.version.not_detected" = "not detected"; +"settings.providers.detail.state.enabled" = "Enabled"; +"settings.providers.detail.state.disabled" = "Disabled"; +"settings.providers.detail.updated.refreshing" = "Refreshing"; +"settings.providers.detail.updated.not_fetched_yet" = "Not fetched yet"; +"settings.providers.metrics.placeholder.disabled_no_data" = "Disabled — no recent data"; +"settings.providers.metrics.placeholder.no_usage" = "No usage yet"; +"settings.providers.cost.accessibility.usage_used" = "Usage used"; +"settings.providers.cost.percent_used" = "%.0f%% used"; +"settings.providers.token_accounts.empty" = "No token accounts yet."; +"settings.providers.token_accounts.remove_selected" = "Remove selected account"; +"settings.providers.token_accounts.label_placeholder" = "Label"; +"settings.providers.token_accounts.add" = "Add"; +"settings.providers.token_accounts.open_token_file" = "Open token file"; +"settings.providers.token_accounts.reload" = "Reload"; +"settings.providers.sidebar.reorder.help" = "Drag to reorder"; +"settings.providers.sidebar.status.disabled_prefix" = "Disabled — %@"; +"settings.providers.sidebar.accessibility.reorder" = "Reorder"; diff --git a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings index f6c9455ad..79e8931b5 100644 --- a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings @@ -8,8 +8,8 @@ "provider.codex.menu.credits" = "积分:%@"; "provider.codex.menu.last_spend" = "最近消费:%@"; -"provider.codex.metadata.session_label" = "会话"; -"provider.codex.metadata.weekly_label" = "周额度"; +"provider.codex.metadata.session_label" = "5 小时使用上限"; +"provider.codex.metadata.weekly_label" = "每周使用上限"; "provider.codex.metadata.credits_hint" = "积分暂不可用;保持 Codex 运行后会自动刷新。"; "provider.codex.metadata.toggle_title" = "显示 Codex 用量"; "provider.codex.no_data_message" = "在 %@ 或 %@ 中未找到 Codex 会话。"; @@ -85,7 +85,7 @@ "menu.card.accessibility.usage_used" = "用量已用"; "menu.card.accessibility.credits_remaining" = "积分剩余"; "menu.card.credits.title" = "积分"; -"menu.card.cost.title" = "成本"; +"menu.card.cost.title" = "花费"; "menu.card.tokens.unit" = "%@ tokens"; "menu.card.cost.today_with_tokens" = "今天:%@ · %@ tokens"; "menu.card.cost.today" = "今天:%@"; @@ -122,8 +122,8 @@ "settings.general.system.start_at_login.title" = "开机启动"; "settings.general.system.start_at_login.subtitle" = "在 Mac 启动时自动打开 CodexBar。"; "settings.general.usage.section" = "用量"; -"settings.general.usage.cost_summary.title" = "显示成本摘要"; -"settings.general.usage.cost_summary.subtitle" = "读取本地 usage 日志,在菜单中显示今天和近 30 天成本。"; +"settings.general.usage.cost_summary.title" = "显示花费摘要"; +"settings.general.usage.cost_summary.subtitle" = "读取本地 usage 日志,在菜单中显示今天和近 30 天花费。"; "settings.general.usage.cost_summary.refresh_hint" = "自动刷新:每小时 · 超时:10 分钟"; "settings.general.automation.section" = "自动化"; "settings.general.automation.refresh_cadence.title" = "刷新频率"; @@ -156,3 +156,106 @@ "settings.providers.menu_bar_metric.option.primary_with_label" = "主窗口(%@)"; "settings.providers.menu_bar_metric.option.secondary_with_label" = "次窗口(%@)"; "settings.providers.menu_bar_metric.option.average_with_labels" = "平均(%@ + %@)"; + +"settings.preferences.tab.general" = "通用"; +"settings.preferences.tab.providers" = "提供方"; +"settings.preferences.tab.display" = "显示"; +"settings.preferences.tab.advanced" = "高级"; +"settings.preferences.tab.about" = "关于"; +"settings.preferences.tab.debug" = "调试"; + +"settings.display.menu_bar.section" = "菜单栏"; +"settings.display.menu_bar.merge_icons.title" = "合并图标"; +"settings.display.menu_bar.merge_icons.subtitle" = "使用单个菜单栏图标,并通过切换器切换 Provider。"; +"settings.display.menu_bar.switcher_icons.title" = "切换器显示图标"; +"settings.display.menu_bar.switcher_icons.subtitle" = "在切换器中显示 Provider 图标(否则显示周用量进度线)。"; +"settings.display.menu_bar.highest_usage.title" = "显示最高使用率 Provider"; +"settings.display.menu_bar.highest_usage.subtitle" = "菜单栏会自动显示最接近限额的 Provider。"; +"settings.display.menu_bar.brand_percent.title" = "菜单栏显示百分比"; +"settings.display.menu_bar.brand_percent.subtitle" = "使用 Provider 品牌图标和百分比替代小动物条。"; +"settings.display.menu_bar.mode.title" = "显示模式"; +"settings.display.menu_bar.mode.subtitle" = "选择菜单栏显示内容(Pace 显示当前用量相对预期节奏)。"; +"settings.display.menu_content.section" = "菜单内容"; +"settings.display.menu_content.usage_as_used.title" = "按已用显示用量"; +"settings.display.menu_content.usage_as_used.subtitle" = "进度条随配额消耗而填充(而不是显示剩余)。"; +"settings.display.menu_content.reset_clock.title" = "重置时间显示为时刻"; +"settings.display.menu_content.reset_clock.subtitle" = "将重置时间显示为绝对时刻而非倒计时。"; +"settings.display.menu_content.optional_usage.title" = "显示积分与额外用量"; +"settings.display.menu_content.optional_usage.subtitle" = "在菜单中显示 Codex 积分与 Claude 额外用量区块。"; +"settings.display.menu_content.all_token_accounts.title" = "显示全部 token 账户"; +"settings.display.menu_content.all_token_accounts.subtitle" = "在菜单中堆叠所有 token 账户(否则显示账户切换条)。"; + +"settings.advanced.keyboard.section" = "快捷键"; +"settings.advanced.keyboard.open_menu.title" = "打开菜单"; +"settings.advanced.keyboard.open_menu.subtitle" = "在任何位置触发菜单栏菜单。"; +"settings.advanced.cli.install" = "安装 CLI"; +"settings.advanced.cli.install.subtitle" = "将 CodexBarCLI 软链接到 /usr/local/bin 和 /opt/homebrew/bin,并命名为 codexbar。"; +"settings.advanced.debug.title" = "显示调试设置"; +"settings.advanced.debug.subtitle" = "在“调试”标签页显示排障工具。"; +"settings.advanced.surprise.title" = "来点惊喜"; +"settings.advanced.surprise.subtitle" = "看看你是否喜欢你的 agents 在上面玩点花样。"; +"settings.advanced.privacy.hide_personal_info.title" = "隐藏个人信息"; +"settings.advanced.privacy.hide_personal_info.subtitle" = "在菜单栏和菜单 UI 中遮蔽邮箱地址。"; +"settings.advanced.keychain.title" = "钥匙串访问"; +"settings.advanced.keychain.caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入将不可用;请在 Providers 中手动粘贴 Cookie 头。"; +"settings.advanced.keychain.disable.title" = "禁用钥匙串访问"; +"settings.advanced.keychain.disable.subtitle" = "启用后将阻止任何钥匙串访问。"; +"settings.advanced.cli.status.helper_not_found" = "在应用包中未找到 CodexBarCLI。"; +"settings.advanced.cli.status.no_write_access" = "无写权限:%@"; +"settings.advanced.cli.status.installed" = "已安装:%@"; +"settings.advanced.cli.status.exists" = "已存在:%@"; +"settings.advanced.cli.status.failed" = "失败:%@"; +"settings.advanced.cli.status.no_writable_dirs" = "未找到可写的 bin 目录。"; + +"settings.about.version" = "版本 %@"; +"settings.about.build" = "构建于 %@"; +"settings.about.tagline" = "愿你的 tokens 永不见底——让 agent 限额始终可见。"; +"settings.about.link.github" = "GitHub"; +"settings.about.link.website" = "网站"; +"settings.about.link.twitter" = "Twitter"; +"settings.about.link.email" = "邮箱"; +"settings.about.updates.auto_check" = "自动检查更新"; +"settings.about.updates.channel" = "更新通道"; +"settings.about.updates.check_now" = "检查更新…"; +"settings.about.updates.unavailable" = "当前构建不可用更新功能。"; +"settings.about.copyright" = "© 2025 Peter Steinberger. MIT 许可证。"; + +"settings.providers.select_provider" = "请选择一个 Provider"; +"settings.providers.alert.cancel" = "取消"; +"settings.providers.subtitle.last_fetch_failed" = "上次获取失败"; +"settings.providers.subtitle.not_fetched_yet" = "尚未获取用量"; +"settings.providers.error.last_fetch_failed" = "%@ 上次获取失败:"; +"settings.providers.error.copy" = "复制错误"; +"settings.providers.error.hide_details" = "隐藏详情"; +"settings.providers.error.show_details" = "显示详情"; +"settings.providers.section.settings" = "设置"; +"settings.providers.section.options" = "选项"; +"settings.providers.section.usage" = "用量"; +"settings.providers.detail.label.state" = "状态"; +"settings.providers.detail.label.source" = "来源"; +"settings.providers.detail.label.version" = "版本"; +"settings.providers.detail.label.updated" = "更新时间"; +"settings.providers.detail.label.status" = "服务状态"; +"settings.providers.detail.label.account" = "账户"; +"settings.providers.detail.label.plan" = "计划"; +"settings.providers.detail.label.credits" = "积分"; +"settings.providers.detail.label.cost" = "花费"; +"settings.providers.detail.help.refresh" = "刷新"; +"settings.providers.detail.version.not_detected" = "未检测到"; +"settings.providers.detail.state.enabled" = "已启用"; +"settings.providers.detail.state.disabled" = "已禁用"; +"settings.providers.detail.updated.refreshing" = "刷新中"; +"settings.providers.detail.updated.not_fetched_yet" = "尚未获取"; +"settings.providers.metrics.placeholder.disabled_no_data" = "已禁用 — 暂无最近数据"; +"settings.providers.metrics.placeholder.no_usage" = "暂无用量"; +"settings.providers.cost.accessibility.usage_used" = "已使用用量"; +"settings.providers.cost.percent_used" = "已使用 %.0f%%"; +"settings.providers.token_accounts.empty" = "暂无 token 账户。"; +"settings.providers.token_accounts.remove_selected" = "移除当前账户"; +"settings.providers.token_accounts.label_placeholder" = "标签"; +"settings.providers.token_accounts.add" = "添加"; +"settings.providers.token_accounts.open_token_file" = "打开 token 文件"; +"settings.providers.token_accounts.reload" = "重新加载"; +"settings.providers.sidebar.reorder.help" = "拖拽以排序"; +"settings.providers.sidebar.status.disabled_prefix" = "已禁用 — %@"; +"settings.providers.sidebar.accessibility.reorder" = "排序"; From 2d847533dc82807b6f8e8af70c2f221d2a0336f4 Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 05:35:56 +0800 Subject: [PATCH 6/8] Refactor window label handling for improved localization in MenuCardView and MenuDescriptor - Updated primary and secondary window labels to utilize localized strings based on the provider type. - Introduced new static methods for generating localized labels in both MenuCardView and MenuDescriptor. - Enhanced consistency in label presentation across different components of the UI. --- Sources/CodexBar/MenuCardView.swift | 18 ++++++++++++++++-- Sources/CodexBar/MenuDescriptor.swift | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 16388ca02..0b1887a49 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -761,7 +761,7 @@ extension UsageMenuCardView.Model { if let primary = snapshot.primary { metrics.append(Metric( id: "primary", - title: input.metadata.sessionLabel, + title: Self.primaryWindowLabel(for: input.provider, fallback: input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -780,7 +780,7 @@ extension UsageMenuCardView.Model { showUsed: input.usageBarsShowUsed) metrics.append(Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: Self.secondaryWindowLabel(for: input.provider, fallback: input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now), @@ -821,6 +821,20 @@ extension UsageMenuCardView.Model { return metrics } + private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.session_label", fallback: "Session") + } + return fallback + } + + private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly") + } + return fallback + } + private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { guard let limit else { return nil } let currentStr = UsageFormatter.tokenCountString(limit.currentValue) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 57cf14890..7cbc15f35 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -117,7 +117,7 @@ struct MenuDescriptor { if let primary = snap.primary { Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: Self.primaryWindowLabel(for: provider, fallback: meta.sessionLabel), window: primary, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -125,7 +125,7 @@ struct MenuDescriptor { if let weekly = snap.secondary { Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: Self.secondaryWindowLabel(for: provider, fallback: meta.weeklyLabel), window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) @@ -165,6 +165,20 @@ struct MenuDescriptor { return Section(entries: entries) } + private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.session_label", fallback: "Session") + } + return fallback + } + + private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String { + if provider == .codex || provider == .codexproxy { + return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly") + } + return fallback + } + private static func accountSection( for provider: UsageProvider, store: UsageStore, From 91bdc9143f0c23dcca51f61482c4b4ccb3d3b382 Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 05:44:15 +0800 Subject: [PATCH 7/8] Remove deprecated menu handling code and update documentation for CLIProxyAPI - Deleted obsolete `StatusItemController+Menu` files to streamline menu management. - Updated `codex.md` documentation by removing outdated CLIProxyAPI management API details to reflect current usage practices. - Enhanced clarity in documentation regarding OAuth API usage for improved user guidance. --- ...usItemController+Menu_20260208235111.swift | 1452 ----------------- ...usItemController+Menu_20260208235623.swift | 1452 ----------------- docs/codex.md | 12 - 3 files changed, 2916 deletions(-) delete mode 100644 .history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift delete mode 100644 .history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift diff --git a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift deleted file mode 100644 index 6d5872ab5..000000000 --- a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235111.swift +++ /dev/null @@ -1,1452 +0,0 @@ -import AppKit -import CodexBarCore -import Observation -import QuartzCore -import SwiftUI - -// MARK: - NSMenu construction - -extension StatusItemController { - private static let menuCardBaseWidth: CGFloat = 310 - 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 { - self.selectedMenuProvider = self.resolvedMenuProvider() - self.lastMenuProvider = self.selectedMenuProvider ?? .codex - provider = self.selectedMenuProvider - } else { - if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { - self.lastMenuProvider = menuProvider - provider = menuProvider - } else if menu === self.fallbackMenu { - self.lastMenuProvider = self.store.enabledProviders().first ?? .codex - provider = nil - } else { - let resolved = self.store.enabledProviders().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 selectedProvider = provider - let enabledProviders = self.store.enabledProviders() - let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) - let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex - let tokenAccountDisplay = 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 canSmartUpdate = self.shouldMergeIcons && - enabledProviders.count > 1 && - switcherProvidersMatch && - 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) - - self.addProviderSwitcherIfNeeded( - to: menu, - enabledProviders: enabledProviders, - selectedProvider: selectedProvider) - // Track which providers the switcher was built with for smart update detection - if self.shouldMergeIcons, enabledProviders.count > 1 { - self.lastSwitcherProviders = enabledProviders - } - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) - let menuContext = MenuCardContext( - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - tokenAccountDisplay: tokenAccountDisplay, - 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) - } - - /// 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) - } - - 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], - selectedProvider: UsageProvider?) - { - guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } - let switcherItem = self.makeProviderSwitcherItem( - providers: enabledProviders, - selected: selectedProvider, - 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()) - } - - private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { - if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { - let accountSnapshots = tokenAccountDisplay.snapshots - let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( - provider: context.currentProvider, - display: tokenAccountDisplay) - if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: aggregateModel, width: context.menuWidth), - id: "menuCard-aggregate", - width: context.menuWidth)) - if !accountSnapshots.isEmpty { - menu.addItem(.separator()) - } - } - if shouldShowAggregateCard { - let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) - if !entries.isEmpty { - let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) - menu.addItem(self.makeMenuCardItem( - compactView, - id: "menuCard-auth-grid", - width: context.menuWidth)) - menu.addItem(.separator()) - } - return false - } - - let cards = accountSnapshots.isEmpty - ? [] - : accountSnapshots.compactMap { accountSnapshot in - self.menuCardModel( - for: context.currentProvider, - snapshotOverride: accountSnapshot.snapshot, - errorOverride: accountSnapshot.error) - } - - if cards.isEmpty, !shouldShowAggregateCard, 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) { - 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): - 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()) - } - } - } - - 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], - selected: UsageProvider?, - menu: NSMenu) -> NSMenuItem - { - let view = ProviderSwitcherView( - providers: providers, - selected: selected, - 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] provider in - guard let self, let menu else { return } - 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.enabledProviders(), 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 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() -> UsageProvider? { - let enabled = self.store.enabledProviders() - if enabled.isEmpty { return .codex } - if let selected = self.selectedMenuProvider, enabled.contains(selected) { - return selected - } - return enabled.first - } - - private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { - if provider == .codex, - let snapshots = self.store.accountSnapshots[provider], - snapshots.count > 1, - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") - { - return TokenAccountMenuDisplay( - provider: provider, - accounts: snapshots.map(\.account), - snapshots: snapshots, - activeIndex: 0, - showAll: true, - showSwitcher: false) - } - - 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 isCodexCLIProxyMultiAuthDisplay( - provider: UsageProvider, - display: TokenAccountMenuDisplay) -> Bool - { - provider == .codex && - display.showAll && - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") - } - - private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { - snapshots.map { snapshot in - let primary = self.percent(for: snapshot.snapshot?.primary) - let secondary = self.percent(for: snapshot.snapshot?.secondary) - let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - let accountTitle: String - if label.isEmpty { - accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" - } else { - accountTitle = label - } - return CodexCLIProxyAuthCompactGridView.Entry( - id: snapshot.id, - accountTitle: accountTitle, - primaryPercent: primary, - secondaryPercent: secondary, - hasError: snapshot.error != nil) - } - } - - private func percent(for window: RateWindow?) -> Double? { - guard let window else { return nil } - if self.settings.usageBarsShowUsed { - return max(0, min(100, window.usedPercent)) - } - return max(0, min(100, window.remainingPercent)) - } - - 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.selectedMenuProvider ?? self.resolvedMenuProvider() - } - if let provider = self.menuProviders[ObjectIdentifier(menu)] { - return provider - } - if menu === self.fallbackMenu { - return nil - } - return self.store.enabledProviders().first ?? .codex - } - - private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a background 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 } - let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() - let isStale = provider.map { self.store.isStale(provider: $0) } ?? self.store.isStale - let hasSnapshot = provider.map { self.store.snapshot(for: $0) != nil } ?? true - guard isStale || !hasSnapshot else { return } - self.refreshStore(forceTokenUsage: false) - } - } - - 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.enabledProviders(), 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) -> 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) - // 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 - let weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent - 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 - 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) { - self.highlightState = highlightState - super.init(rootView: rootView) - } - - required init(rootView: Content) { - self.highlightState = MenuCardHighlightState() - super.init(rootView: rootView) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - 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 struct CodexCLIProxyAuthCompactGridView: View { - struct Entry: Identifiable { - let id: UUID - let accountTitle: String - let primaryPercent: Double? - let secondaryPercent: Double? - let hasError: Bool - } - - let entries: [Entry] - @Environment(\.menuItemHighlighted) private var isHighlighted - - private var columns: [GridItem] { - [ - GridItem(.flexible(minimum: 120), spacing: 8), - GridItem(.flexible(minimum: 120), spacing: 8), - ] - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - let titleFormat = L10n.tr( - "menu.codex.cliproxy.auth_grid.title", - fallback: "Codex auth entries (%d)") - Text(String(format: titleFormat, locale: .current, self.entries.count)) - .font(.footnote.weight(.semibold)) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - - LazyVGrid(columns: self.columns, spacing: 8) { - ForEach(self.entries) { entry in - AccountCell( - entry: entry, - isHighlighted: self.isHighlighted) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } - - private struct AccountCell: View { - let entry: CodexCLIProxyAuthCompactGridView.Entry - let isHighlighted: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(self.entry.accountTitle) - .font(.caption.weight(.medium)) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - - HStack(spacing: 12) { - RingBadge( - percent: self.entry.primaryPercent, - isError: self.entry.hasError, - tint: Color(nsColor: NSColor.systemTeal), - isHighlighted: self.isHighlighted) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - RingBadge( - percent: self.entry.secondaryPercent, - isError: self.entry.hasError, - tint: Color(nsColor: NSColor.systemIndigo), - isHighlighted: self.isHighlighted) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } - .frame(maxWidth: .infinity, alignment: .center) - } - .padding(.horizontal, 6) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) - } - } - - private struct RingBadge: View { - let percent: Double? - let isError: Bool - let tint: Color - let isHighlighted: Bool - - private var normalizedPercent: Double { - guard let percent else { return 0 } - return max(0, min(100, percent)) - } - - var body: some View { - GeometryReader { proxy in - let diameter = min(proxy.size.width, proxy.size.height) - let lineWidth = max(3, diameter * 0.11) - let fontSize = max(10, diameter * 0.32) - - ZStack { - Circle() - .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) - Circle() - .trim(from: 0, to: self.normalizedPercent / 100) - .stroke( - MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .rotationEffect(.degrees(-90)) - - if self.isError { - Image(systemName: "xmark") - .font(.system(size: fontSize, weight: .bold)) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - } else if self.percent == nil { - Text("—") - .font(.system(size: fontSize, weight: .semibold)) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } else { - Text("\(Int(self.normalizedPercent.rounded()))") - .font(.system(size: fontSize, weight: .semibold)) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - } - } - .frame(width: diameter, height: diameter) - .position(x: proxy.size.width / 2, y: proxy.size.height / 2) - } - .aspectRatio(1, contentMode: .fit) - } - } - - private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem( - title: L10n.tr("menu.action.buy_credits", fallback: "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: L10n.tr("menu.action.credits_history", fallback: "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: L10n.tr("menu.action.usage_breakdown", fallback: "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.enabledProviders() - 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.enabledProviders().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 input = UsageMenuCardView.Model.Input( - provider: target, - metadata: metadata, - sourceLabel: self.store.sourceLabel(for: target), - 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, - hidePersonalInfo: self.settings.hidePersonalInfo, - now: Date()) - return UsageMenuCardView.Model.make(input) - } - - @objc private func menuCardNoOp(_ sender: NSMenuItem) { - _ = sender - } - - 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 - } -} diff --git a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift b/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift deleted file mode 100644 index 09046cc45..000000000 --- a/.history/Sources/CodexBar/StatusItemController+Menu_20260208235623.swift +++ /dev/null @@ -1,1452 +0,0 @@ -import AppKit -import CodexBarCore -import Observation -import QuartzCore -import SwiftUI - -// MARK: - NSMenu construction - -extension StatusItemController { - private static let menuCardBaseWidth: CGFloat = 310 - 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 { - self.selectedMenuProvider = self.resolvedMenuProvider() - self.lastMenuProvider = self.selectedMenuProvider ?? .codex - provider = self.selectedMenuProvider - } else { - if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { - self.lastMenuProvider = menuProvider - provider = menuProvider - } else if menu === self.fallbackMenu { - self.lastMenuProvider = self.store.enabledProviders().first ?? .codex - provider = nil - } else { - let resolved = self.store.enabledProviders().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 selectedProvider = provider - let enabledProviders = self.store.enabledProviders() - let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) - let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex - let tokenAccountDisplay = 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 canSmartUpdate = self.shouldMergeIcons && - enabledProviders.count > 1 && - switcherProvidersMatch && - 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) - - self.addProviderSwitcherIfNeeded( - to: menu, - enabledProviders: enabledProviders, - selectedProvider: selectedProvider) - // Track which providers the switcher was built with for smart update detection - if self.shouldMergeIcons, enabledProviders.count > 1 { - self.lastSwitcherProviders = enabledProviders - } - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) - let menuContext = MenuCardContext( - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - tokenAccountDisplay: tokenAccountDisplay, - 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) - } - - /// 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) - } - - 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], - selectedProvider: UsageProvider?) - { - guard self.shouldMergeIcons, enabledProviders.count > 1 else { return } - let switcherItem = self.makeProviderSwitcherItem( - providers: enabledProviders, - selected: selectedProvider, - 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()) - } - - private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { - if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { - let accountSnapshots = tokenAccountDisplay.snapshots - let shouldShowAggregateCard = self.isCodexCLIProxyMultiAuthDisplay( - provider: context.currentProvider, - display: tokenAccountDisplay) - if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: aggregateModel, width: context.menuWidth), - id: "menuCard-aggregate", - width: context.menuWidth)) - if !accountSnapshots.isEmpty { - menu.addItem(.separator()) - } - } - if shouldShowAggregateCard { - let entries = self.codexCLIProxyCompactEntries(from: accountSnapshots) - if !entries.isEmpty { - let compactView = CodexCLIProxyAuthCompactGridView(entries: entries) - menu.addItem(self.makeMenuCardItem( - compactView, - id: "menuCard-auth-grid", - width: context.menuWidth)) - menu.addItem(.separator()) - } - return false - } - - let cards = accountSnapshots.isEmpty - ? [] - : accountSnapshots.compactMap { accountSnapshot in - self.menuCardModel( - for: context.currentProvider, - snapshotOverride: accountSnapshot.snapshot, - errorOverride: accountSnapshot.error) - } - - if cards.isEmpty, !shouldShowAggregateCard, 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) { - 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): - 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()) - } - } - } - - 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], - selected: UsageProvider?, - menu: NSMenu) -> NSMenuItem - { - let view = ProviderSwitcherView( - providers: providers, - selected: selected, - 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] provider in - guard let self, let menu else { return } - 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.enabledProviders(), 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 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() -> UsageProvider? { - let enabled = self.store.enabledProviders() - if enabled.isEmpty { return .codex } - if let selected = self.selectedMenuProvider, enabled.contains(selected) { - return selected - } - return enabled.first - } - - private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { - if provider == .codex, - let snapshots = self.store.accountSnapshots[provider], - snapshots.count > 1, - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") - { - return TokenAccountMenuDisplay( - provider: provider, - accounts: snapshots.map(\.account), - snapshots: snapshots, - activeIndex: 0, - showAll: true, - showSwitcher: false) - } - - 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 isCodexCLIProxyMultiAuthDisplay( - provider: UsageProvider, - display: TokenAccountMenuDisplay) -> Bool - { - provider == .codex && - display.showAll && - self.store.sourceLabel(for: .codex).localizedCaseInsensitiveContains("cliproxy-api") - } - - private func codexCLIProxyCompactEntries(from snapshots: [TokenAccountUsageSnapshot]) -> [CodexCLIProxyAuthCompactGridView.Entry] { - snapshots.map { snapshot in - let primary = self.percent(for: snapshot.snapshot?.primary) - let secondary = self.percent(for: snapshot.snapshot?.secondary) - let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - let accountTitle: String - if label.isEmpty { - accountTitle = snapshot.snapshot?.accountEmail(for: .codex) ?? "codex" - } else { - accountTitle = label - } - return CodexCLIProxyAuthCompactGridView.Entry( - id: snapshot.id, - accountTitle: accountTitle, - primaryPercent: primary, - secondaryPercent: secondary, - hasError: snapshot.error != nil) - } - } - - private func percent(for window: RateWindow?) -> Double? { - guard let window else { return nil } - if self.settings.usageBarsShowUsed { - return max(0, min(100, window.usedPercent)) - } - return max(0, min(100, window.remainingPercent)) - } - - 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.selectedMenuProvider ?? self.resolvedMenuProvider() - } - if let provider = self.menuProviders[ObjectIdentifier(menu)] { - return provider - } - if menu === self.fallbackMenu { - return nil - } - return self.store.enabledProviders().first ?? .codex - } - - private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a background 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 } - let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() - let isStale = provider.map { self.store.isStale(provider: $0) } ?? self.store.isStale - let hasSnapshot = provider.map { self.store.snapshot(for: $0) != nil } ?? true - guard isStale || !hasSnapshot else { return } - self.refreshStore(forceTokenUsage: false) - } - } - - 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.enabledProviders(), 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) -> 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) - // 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 - let weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent - 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 - 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) { - self.highlightState = highlightState - super.init(rootView: rootView) - } - - required init(rootView: Content) { - self.highlightState = MenuCardHighlightState() - super.init(rootView: rootView) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - 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 struct CodexCLIProxyAuthCompactGridView: View { - struct Entry: Identifiable { - let id: UUID - let accountTitle: String - let primaryPercent: Double? - let secondaryPercent: Double? - let hasError: Bool - } - - let entries: [Entry] - @Environment(\.menuItemHighlighted) private var isHighlighted - - private var columns: [GridItem] { - [ - GridItem(.flexible(minimum: 120), spacing: 8), - GridItem(.flexible(minimum: 120), spacing: 8), - ] - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - let titleFormat = L10n.tr( - "menu.codex.cliproxy.auth_grid.title", - fallback: "Codex auth entries (%d)") - Text(String(format: titleFormat, locale: .current, self.entries.count)) - .font(.footnote.weight(.semibold)) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - - LazyVGrid(columns: self.columns, spacing: 8) { - ForEach(self.entries) { entry in - AccountCell( - entry: entry, - isHighlighted: self.isHighlighted) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } - - private struct AccountCell: View { - let entry: CodexCLIProxyAuthCompactGridView.Entry - let isHighlighted: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(self.entry.accountTitle) - .font(.caption.weight(.medium)) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - - HStack(spacing: 16) { - RingBadge( - percent: self.entry.primaryPercent, - isError: self.entry.hasError, - tint: Color(nsColor: NSColor.systemTeal), - isHighlighted: self.isHighlighted) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - RingBadge( - percent: self.entry.secondaryPercent, - isError: self.entry.hasError, - tint: Color(nsColor: NSColor.systemIndigo), - isHighlighted: self.isHighlighted) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } - .frame(maxWidth: .infinity, alignment: .center) - } - .padding(.horizontal, 6) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(MenuHighlightStyle.progressTrack(self.isHighlighted))) - } - } - - private struct RingBadge: View { - let percent: Double? - let isError: Bool - let tint: Color - let isHighlighted: Bool - - private var normalizedPercent: Double { - guard let percent else { return 0 } - return max(0, min(100, percent)) - } - - var body: some View { - GeometryReader { proxy in - let diameter = min(proxy.size.width, proxy.size.height) - let lineWidth = max(3, diameter * 0.11) - let fontSize = max(10, diameter * 0.32) - - ZStack { - Circle() - .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth) - Circle() - .trim(from: 0, to: self.normalizedPercent / 100) - .stroke( - MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .rotationEffect(.degrees(-90)) - - if self.isError { - Image(systemName: "xmark") - .font(.system(size: fontSize, weight: .bold)) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - } else if self.percent == nil { - Text("—") - .font(.system(size: fontSize, weight: .semibold)) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } else { - Text("\(Int(self.normalizedPercent.rounded()))") - .font(.system(size: fontSize, weight: .semibold)) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - } - } - .frame(width: diameter, height: diameter) - .position(x: proxy.size.width / 2, y: proxy.size.height / 2) - } - .aspectRatio(1, contentMode: .fit) - } - } - - private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem( - title: L10n.tr("menu.action.buy_credits", fallback: "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: L10n.tr("menu.action.credits_history", fallback: "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: L10n.tr("menu.action.usage_breakdown", fallback: "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.enabledProviders() - 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.enabledProviders().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 input = UsageMenuCardView.Model.Input( - provider: target, - metadata: metadata, - sourceLabel: self.store.sourceLabel(for: target), - 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, - hidePersonalInfo: self.settings.hidePersonalInfo, - now: Date()) - return UsageMenuCardView.Model.make(input) - } - - @objc private func menuCardNoOp(_ sender: NSMenuItem) { - _ = sender - } - - 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 - } -} diff --git a/docs/codex.md b/docs/codex.md index 204f42459..509a5e112 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -27,18 +27,6 @@ Usage source picker: 1) OpenAI web dashboard (when available). 2) Codex CLI RPC, with CLI PTY fallback when needed. -### CLIProxyAPI management API -- Usage source: `CLIProxyAPI` (`--source api`). -- Settings: - - `CLIProxyAPI URL` (default `http://127.0.0.1:8317`). - - `CLIProxy management key` (required). - - `CLIProxy auth_index` (optional). - - CLI (`codexbar --source api`) 留空时会遍历并输出所有可用 Codex auth 条目。 - - App 运行时留空会自动选择第一个可用 Codex auth 条目。 -- Calls: - 1) `GET /v0/management/auth-files` to resolve a Codex `auth_index`. - 2) `POST /v0/management/api-call` to proxy `GET https://chatgpt.com/backend-api/wham/usage`. - ### OAuth API (preferred for the app) - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). - Refreshes access tokens when `last_refresh` is older than 8 days. From 8ee2a346368f935f7f1e68b31b771097dadc38bb Mon Sep 17 00:00:00 2001 From: baicai-1145 <3423714059@qq.com> Date: Mon, 9 Feb 2026 05:48:27 +0800 Subject: [PATCH 8/8] Update README.md to include CLIProxyAPI features and language options - Added details about first-class CLIProxyAPI usage paths for Codex, Gemini, and Antigravity. - Introduced app language options: System, English, and Simplified Chinese. - Updated provider documentation to reflect new CLIProxy integration and management capabilities. --- CLIProxyAPI | 1 + README.md | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 160000 CLIProxyAPI diff --git a/CLIProxyAPI b/CLIProxyAPI new file mode 160000 index 000000000..7e9d0db6a --- /dev/null +++ b/CLIProxyAPI @@ -0,0 +1 @@ +Subproject commit 7e9d0db6aac21734d84038f843336306246ecadd diff --git a/README.md b/README.md index 757fbf030..ca4bdd080 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Now includes first-class CLIProxyAPI usage paths, plus app language options (System / English / 简体中文). + CodexBar menu screenshot ## Install @@ -28,14 +30,18 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - 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. +- Optional: Settings → General → CLIProxyAPI to set base URL, management key, and optional `auth_index`. ## Providers - [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras. +- [CLIProxy Codex](docs/codex.md) — Codex quota via CLIProxyAPI management endpoints, with multi-auth aggregation + per-auth drill-down. - [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). +- CLIProxy Gemini — Gemini quota via CLIProxyAPI (`gemini` auth entries). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. +- CLIProxy Antigravity — Antigravity quota via CLIProxyAPI (`antigravity` auth entries). - [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. @@ -58,12 +64,14 @@ The menu bar icon is a tiny two-bar meter: - 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). +- CLIProxyAPI integration for Codex/Gemini/Antigravity with multi-auth support. - 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. - 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. +- Built-in i18n language switcher: follow system, English, and Simplified Chinese. - Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored). ## Privacy note