diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4e38daa..84e6e3e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ on Columbus Labs QuotaKit releases and product-facing changes. Codex account/auth hardening, MiniMax quota fixes, menu performance updates, merged provider-switching hang fixes, Claude probe cleanup, Antigravity/Alibaba/Cursor fixes, and additional Mac localizations. +- Synced upstream CodexBar Mac improvements through `0.33.1` development, + including a security fix that blocks credentialed provider redirects leaving + the original HTTPS origin, a new Devin usage provider, Cursor legacy + request-quota and Full Disk Access hint fixes, Copilot unlimited chat quota + display, Codex cost visibility without quotas, updated Claude usage pricing + and web session recovery, Doubao false-exhaustion fixes, cost scanner + threading and cancellation overhauls, broad menu performance and + width-stability work, a configurable terminal app for Open Terminal, expanded + MiMo browser support, and Japanese localization. ## 0.32.4.4 / iOS 1.11.1 — 2026-06-08 diff --git a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift index 62d258f09..dd5a3a3d1 100644 --- a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift +++ b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift @@ -119,6 +119,7 @@ enum ProviderColorPalette { (["groq", "groqcloud", "groqapi"], RawColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255)), (["llmproxy"], RawColor(red: 36 / 255, green: 180 / 255, blue: 126 / 255)), (["deepgram"], RawColor(red: 0.49, green: 0.23, blue: 0.93)), + (["devin"], RawColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), ] var table: [String: RawColor] = [:] diff --git a/Sources/CodexBar/ClickToCopyOverlay.swift b/Sources/CodexBar/ClickToCopyOverlay.swift index 114602854..b6ea3ea7d 100644 --- a/Sources/CodexBar/ClickToCopyOverlay.swift +++ b/Sources/CodexBar/ClickToCopyOverlay.swift @@ -1,6 +1,35 @@ import AppKit import SwiftUI +@MainActor +enum MenuPasteboardCopy { + typealias DeferredAction = @MainActor @Sendable () -> Void + typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void + typealias Writer = @MainActor @Sendable (String) -> Void + + static func perform( + _ text: String, + scheduler: Scheduler = Self.schedule, + writer: @escaping Writer = Self.write, + completion: @escaping DeferredAction = {}) + { + scheduler { + writer(text) + completion() + } + } + + private static func schedule(_ action: @escaping DeferredAction) { + DispatchQueue.main.async(execute: action) + } + + private static func write(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} + struct ClickToCopyOverlay: NSViewRepresentable { let copyText: String @@ -9,15 +38,25 @@ struct ClickToCopyOverlay: NSViewRepresentable { } func updateNSView(_ nsView: ClickToCopyView, context: Context) { + // Guard against no-op writes to avoid AppKit view invalidation on every + // parent card SwiftUI diff (each MenuCardView body re-eval runs through + // .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even + // when copyText is unchanged). + guard nsView.copyText != self.copyText else { return } nsView.copyText = self.copyText } } final class ClickToCopyView: NSView { var copyText: String + private let copyAction: (String) -> Void - init(copyText: String) { + init( + copyText: String, + copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) }) + { self.copyText = copyText + self.copyAction = copyAction super.init(frame: .zero) self.wantsLayer = false } @@ -33,8 +72,6 @@ final class ClickToCopyView: NSView { override func mouseDown(with event: NSEvent) { _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + self.copyAction(self.copyText) } } diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index e1a35e28a..0f54e1696 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -131,47 +131,68 @@ struct CostHistoryChartMenuView: View { .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) - ForEach(detail.rows) { row in - HStack(alignment: .top, spacing: 8) { - Rectangle() - .fill(row.accentColor) - .frame( - width: 2, - height: Self.accentHeight(for: row)) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 1) { - Text(row.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailTitleLineHeight, alignment: .leading) - if let subtitle = row.subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + if model.maxRenderedBreakdownRows > 0 { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight(for: row)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) } - if let modeSubtitle = row.modeSubtitle { - Text(modeSubtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + ForEach( + 0.. (count: Int, height: CGFloat) { guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) } let renderedRows = Array( - self.sortedBreakdown(breakdown) - .prefix(self.maxVisibleDetailLines)) + self.orderedBreakdownItems(breakdown) + .prefix(self.detailViewportRowCount(itemCount: breakdown.count))) let height = renderedRows.reduce(CGFloat(0)) { total, item in total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item)) } @@ -353,8 +374,18 @@ struct CostHistoryChartMenuView: View { private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + - maxRowsHeight + - (CGFloat(maxBreakdownRows) * self.detailSpacing) + self.detailRowsViewportHeight( + maxBreakdownRows: maxBreakdownRows, + maxRowsHeight: maxRowsHeight) + + self.detailSpacing + } + + private static func detailRowsViewportHeight( + maxBreakdownRows: Int, + maxRowsHeight: CGFloat) -> CGFloat + { + guard maxBreakdownRows > 0 else { return 0 } + return maxRowsHeight + (CGFloat(maxBreakdownRows - 1) * self.detailSpacing) } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { @@ -400,10 +431,9 @@ struct CostHistoryChartMenuView: View { proxy: ChartProxy, geo: GeometryProxy) { - guard let location else { - if self.selectedDateKey != nil { self.selectedDateKey = nil } - return - } + // Keep the last hovered day selected when the pointer leaves the chart so the adjacent + // model-breakdown scroller remains interactive. The selection resets with the menu view. + guard let location else { return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] @@ -457,8 +487,7 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } - return Self.sortedBreakdown(breakdown) - .prefix(Self.maxVisibleDetailLines) + return Self.orderedBreakdownItems(breakdown) .enumerated() .map { index, item in DetailRow( @@ -470,7 +499,7 @@ struct CostHistoryChartMenuView: View { } } - private static func sortedBreakdown( + static func orderedBreakdownItems( _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] { breakdown.sorted { lhs, rhs in @@ -486,6 +515,14 @@ struct CostHistoryChartMenuView: View { } } + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { UsageFormatter.modelCostDetail( item.modelName, diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index 7a4586514..d3678e018 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -65,6 +65,34 @@ enum KeychainPromptCoordinator { BrowserCookieKeychainPromptHandler.handler = { context in self.presentBrowserCookiePrompt(context) } + self.disableKeychainForUnbundledExecutableIfNeeded() + } + + private static let unbundledExecutableCheckLock = NSLock() + private nonisolated(unsafe) static var didCheckUnbundledExecutable = false + + static func disableKeychainForUnbundledExecutableIfNeeded() { + self.unbundledExecutableCheckLock.lock() + guard !self.didCheckUnbundledExecutable else { + self.unbundledExecutableCheckLock.unlock() + return + } + self.didCheckUnbundledExecutable = true + self.unbundledExecutableCheckLock.unlock() + + let executablePath = Bundle.main.executableURL?.path ?? "" + guard Self.isUnbundledCodexBarExecutable(executablePath) else { return } + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + Self.log.warning( + "Unbundled CodexBar executable detected; disabling keychain access to avoid repeated prompts", + metadata: ["doc": "docs/DEVELOPMENT_SETUP.md"]) + } + + static func isUnbundledCodexBarExecutable(_ executablePath: String) -> Bool { + guard executablePath.hasPrefix("/") else { return false } + let executableURL = URL(fileURLWithPath: executablePath).standardizedFileURL + return executableURL.lastPathComponent == "CodexBar" + && !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") }) } private static func presentKeychainPrompt(_ context: KeychainPromptContext) { diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 3ba3a7c02..f08d6b3bf 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -29,6 +29,13 @@ extension UsageMenuCardView.Model { self.placeholder != nil } + var usesStackedDetailLayout: Bool { + !self.metrics.isEmpty || + self.creditsText != nil || + self.providerCost != nil || + self.tokenUsage != nil + } + static func progressColor(for provider: UsageProvider) -> Color { if provider == .cursor || provider == .elevenlabs { return Color(nsColor: .labelColor) @@ -52,7 +59,7 @@ extension UsageMenuCardView.Model { } if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { - return L("No usage yet") + return self.hasLocalCodexTokenUsage(input) ? nil : L("No usage yet") } return nil @@ -70,6 +77,12 @@ extension UsageMenuCardView.Model { return lastError } + private static func hasLocalCodexTokenUsage(_ input: Input) -> Bool { + input.provider == .codex && + input.tokenCostUsageEnabled && + self.tokenUsageSnapshot(input: input) != nil + } + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { let currentError = lastError ?? input.lastError if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1f5d6a8d1..8b2ce17c9 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -143,7 +143,7 @@ struct UsageMenuCardView: View { Divider() } - if self.model.metrics.isEmpty { + if !self.model.usesStackedDetailLayout { if let dashboard = self.model.inlineUsageDashboard { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { @@ -172,6 +172,10 @@ struct UsageMenuCardView: View { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) } } } @@ -244,9 +248,7 @@ struct UsageMenuCardView: View { } private var hasDetails: Bool { - self.model.hasUsageContent || - self.model.tokenUsage != nil || - self.model.providerCost != nil + self.model.hasUsageContent || self.model.usesStackedDetailLayout } } @@ -322,17 +324,7 @@ private struct CopyIconButton: View { var body: some View { Button { - self.copyToPasteboard() - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } - self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { - self.didCopy = false - } - } + self.handleCopy() } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -343,10 +335,16 @@ private struct CopyIconButton: View { .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error")) } - private func copyToPasteboard() { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + private func handleCopy() { + let text = self.copyText + self.resetTask?.cancel() + MenuPasteboardCopy.perform(text, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) + self.didCopy = false + } + }) } } @@ -1049,7 +1047,7 @@ extension UsageMenuCardView.Model { return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) } - if isRefreshing, snapshot == nil { + if isRefreshing { return ("\(L("Refreshing"))…", .loading) } @@ -1219,9 +1217,16 @@ extension UsageMenuCardView.Model { if input.provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } - let primaryLabel = input.provider == .grok - ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel - : input.metadata.sessionLabel + // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool — + // relabel the primary bar so it reads as a request count instead of a dollar percentage. + let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil { + "Requests" + } else if input.provider == .grok { + GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata + .sessionLabel + } else { + input.metadata.sessionLabel + } return ( L(primaryLabel), L(input.metadata.weeklyLabel), @@ -1326,6 +1331,14 @@ extension UsageMenuCardView.Model { primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } + // Legacy request-based Cursor plans: surface the raw used/limit quota on its own line, + // since the percentage bar and pace detail alone never spell out the request cap. + if input.provider == .cursor, let requests = input.snapshot?.cursorRequests { + primaryDetailText = String( + format: L("Request quota: %@ / %@"), + "\(requests.used)", + "\(requests.limit)") + } if input.provider == .synthetic, let regen = Self.syntheticRollingRegenDetail( window: primary, diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index ae995cc1b..5a5b36f88 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -64,8 +64,9 @@ struct PlanUtilizationHistoryChartMenuView: View { } private let provider: UsageProvider - private let histories: [PlanUtilizationSeriesHistory] - private let snapshot: UsageSnapshot? + private let visibleSeries: [VisibleSeries] + private let modelsBySeriesID: [String: Model] + private let emptyModel: Model private let width: CGFloat @State private var selectedSeriesID: String? @@ -78,32 +79,33 @@ struct PlanUtilizationHistoryChartMenuView: View { width: CGFloat) { self.provider = provider - self.histories = histories - self.snapshot = snapshot + let visibleSeries = Self.visibleSeries( + histories: histories, + provider: provider, + snapshot: snapshot) + let referenceDate = Date() + self.visibleSeries = visibleSeries + self.modelsBySeriesID = Dictionary(uniqueKeysWithValues: visibleSeries.map { + ($0.id, Self.makeModel(history: $0.history, provider: provider, referenceDate: referenceDate)) + }) + self.emptyModel = Self.emptyModel(provider: provider) self.width = width } var body: some View { - let visibleSeries = Self.visibleSeries( - histories: self.histories, - provider: self.provider, - snapshot: self.snapshot) - let effectiveSelectedSeries = visibleSeries.first(where: { $0.id == self.selectedSeriesID }) ?? visibleSeries - .first - let model = Self.makeModel( - history: effectiveSelectedSeries?.history, - provider: self.provider, - referenceDate: Date()) + let effectiveSelectedSeries = self.visibleSeries.first(where: { $0.id == self.selectedSeriesID }) + ?? self.visibleSeries.first + let model = effectiveSelectedSeries.flatMap { self.modelsBySeriesID[$0.id] } ?? self.emptyModel VStack(alignment: .leading, spacing: 10) { - if visibleSeries.count > 1 { + if self.visibleSeries.count > 1 { Picker(selection: Binding( get: { effectiveSelectedSeries?.id ?? "" }, set: { newValue in self.selectedSeriesID = newValue self.selectedPointID = nil })) { - ForEach(visibleSeries) { series in + ForEach(self.visibleSeries) { series in Text(series.title).tag(series.id) } } label: { @@ -172,9 +174,9 @@ struct PlanUtilizationHistoryChartMenuView: View { .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) - .task(id: visibleSeries.map(\.id).joined(separator: ",")) { - guard let firstVisibleSeries = visibleSeries.first else { return } - guard !visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } + .task(id: self.visibleSeries.map(\.id).joined(separator: ",")) { + guard let firstVisibleSeries = self.visibleSeries.first else { return } + guard !self.visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } self.selectedSeriesID = firstVisibleSeries.id self.selectedPointID = nil } @@ -228,12 +230,12 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func mergedEntries( + nonisolated static func mergedEntries( _ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry] { - entries.reduce(into: []) { result, entry in - guard !result.contains(entry) else { return } - result.append(entry) + var seen: Set = [] + return entries.filter { entry in + seen.insert(entry).inserted } } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 13b6b4d97..e9843f309 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -28,7 +28,7 @@ struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Expressib } } -struct PlanUtilizationHistoryEntry: Codable, Equatable { +struct PlanUtilizationHistoryEntry: Codable, Equatable, Hashable { let capturedAt: Date let usedPercent: Double let resetsAt: Date? diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 0854310f9..ee10ab1fe 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -15,6 +15,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case dutch = "nl" case ukrainian = "uk" case vietnamese = "vi" + case japanese = "ja" var id: String { self.rawValue @@ -34,6 +35,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .dutch: L("language_dutch") case .ukrainian: L("language_ukrainian") case .vietnamese: L("language_vietnamese") + case .japanese: L("language_japanese") } } } @@ -74,6 +76,26 @@ struct GeneralPane: View { } } + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("terminal_app_title")) + .font(.body) + Text(L("terminal_app_subtitle")) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Picker(L("terminal_app_title"), selection: self.$settings.terminalApp) { + ForEach(TerminalApp.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + PreferenceToggleRow( title: L("start_at_login_title"), subtitle: L("start_at_login_subtitle"), diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index 0a6bb90d6..173822d1f 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -5,6 +5,12 @@ import Foundation final class AugmentProviderRuntime: ProviderRuntime { let id: UsageProvider = .augment private var keepalive: AugmentSessionKeepalive? + #if DEBUG + private(set) var _test_keepaliveStopCount = 0 + var _test_isKeepaliveRunning: Bool { + self.keepalive != nil + } + #endif func start(context: ProviderRuntimeContext) { self.updateKeepalive(context: context) @@ -83,8 +89,12 @@ final class AugmentProviderRuntime: ProviderRuntime { private func stopKeepalive(context: ProviderRuntimeContext, reason: String) { #if os(macOS) - self.keepalive?.stop() + guard let keepalive = self.keepalive else { return } + keepalive.stop() self.keepalive = nil + #if DEBUG + self._test_keepaliveStopCount += 1 + #endif context.store.augmentLogger.info("Augment keepalive stopped (\(reason))") #endif } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 915cff965..c833629a0 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -228,10 +228,37 @@ struct ClaudeProviderImplementation: ProviderImplementation { func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { + if self.shouldOpenBrowserForWebSessionError(context: context) { + return ("Re-login at claude.ai", .loginToProvider(url: "https://claude.ai/")) + } guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil } return ("Open Terminal", .openTerminal(command: "claude")) } + @MainActor + private func shouldOpenBrowserForWebSessionError(context: ProviderMenuLoginContext) -> Bool { + let settings = context.settings.claudeSettingsSnapshot(tokenOverride: nil) + let source = settings.usageDataSource + guard source == .auto || source == .web, + settings.cookieSource == .auto, + let error = context.store.error(for: .claude) + else { return false } + + let sessionErrors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + if sessionErrors.contains(error) { + return true + } + + guard error == ProviderFetchError.noAvailableStrategy(.claude).localizedDescription else { return false } + return context.store.fetchAttempts(for: .claude).contains { + $0.strategyID == "claude.web" && !$0.wasAvailable + } + } + @MainActor private func shouldOpenTerminalForOAuthError(store: UsageStore) -> Bool { guard store.error(for: .claude) != nil else { return false } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 1f7987b16..a0be2ddb3 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -141,6 +141,7 @@ extension SettingsStore { } set { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -166,6 +167,7 @@ extension SettingsStore { @discardableResult func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -210,6 +212,7 @@ extension SettingsStore { func invalidateCodexAccountReconciliationSnapshotCache() { self.cachedCodexAccountReconciliationSnapshot = nil + self.codexAccountReconciliationGeneration &+= 1 } var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { @@ -230,16 +233,84 @@ extension SettingsStore { return cached.snapshot } - let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + let snapshot = self.codexAccountSnapshotLoader(activeSource: activeSource)() + let loadedAt = Date() if cacheInterval > 0 { self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( activeSource: activeSource, - loadedAt: now, + loadedAt: loadedAt, snapshot: snapshot) } + if activeSource == self.codexPersistedActiveSource { + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } return snapshot } + /// Menu rendering must stay side-effect free: no `auth.json` reads, JWT parsing, or fingerprint hashing. + var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection? { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return nil + } + return cached.projection + } + + var codexAccountMenuProjectionNeedsRevalidation: Bool { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return true + } + return Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval + } + + func revalidateCodexAccountMenuProjection() async -> CodexAccountMenuProjectionRevalidationResult { + guard self.codexAccountMenuProjectionNeedsRevalidation else { return .skipped } + + let activeSource = self.codexPersistedActiveSource + let generation = self.codexAccountReconciliationGeneration + let loader = self.codexAccountSnapshotLoader(activeSource: activeSource) + let snapshot = await Self.loadCodexAccountSnapshot(loader) + + guard generation == self.codexAccountReconciliationGeneration, + activeSource == self.codexPersistedActiveSource + else { + return .discarded + } + + let now = Date() + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let previousProjection = self.cachedCodexAccountMenuProjection.flatMap { cached in + cached.activeSource == activeSource ? cached.projection : nil + } + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: now, + projection: projection) + if Self.codexAccountReconciliationSnapshotCacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return previousProjection == projection ? .unchanged : .updated + } + + @concurrent + private nonisolated static func loadCodexAccountSnapshot( + _ loader: @escaping @Sendable () -> CodexAccountReconciliationSnapshot) + async -> CodexAccountReconciliationSnapshot + { + loader() + } + var codexVisibleAccountProjection: CodexVisibleAccountProjection { CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } @@ -257,11 +328,8 @@ extension SettingsStore { } func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) { - if self.selectCodexVisibleAccount(id: account.id) { - return - } - // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. - self.invalidateCodexAccountReconciliationSnapshotCache() + // The row already carries the exact source it represented. Re-resolving its ID would synchronously + // reload auth state from the menu click callback and can also fail after a stale snapshot is rendered. self.codexActiveSource = account.selectionSource } @@ -283,6 +351,18 @@ extension SettingsStore { self.codexVisibleAccountProjection.source(forVisibleAccountID: id) } + private func codexAccountSnapshotLoader( + activeSource: CodexActiveSource) -> @Sendable () -> CodexAccountReconciliationSnapshot + { + #if DEBUG + if let loader = self._test_codexAccountSnapshotLoader { + return { loader(activeSource) } + } + #endif + let reconciler = self.codexAccountReconciler(activeSource: activeSource) + return { reconciler.loadSnapshot() } + } + private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler { let baseEnvironment = self.codexReconciliationEnvironment() #if DEBUG @@ -518,10 +598,15 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb } extension SettingsStore { + private func invalidateCodexAccountReconciliationCachesForTesting() { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil + } + var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } } @@ -529,7 +614,7 @@ extension SettingsStore { var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } } @@ -537,7 +622,7 @@ extension SettingsStore { var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } } @@ -545,7 +630,7 @@ extension SettingsStore { var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } } @@ -553,7 +638,7 @@ extension SettingsStore { var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } } @@ -561,7 +646,7 @@ extension SettingsStore { var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } set { - self.invalidateCodexAccountReconciliationSnapshotCache() + self.invalidateCodexAccountReconciliationCachesForTesting() CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } } diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift new file mode 100644 index 000000000..2ebb5003d --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -0,0 +1,123 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct DevinProviderImplementation: ProviderImplementation { + let id: UsageProvider = .devin + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.devinCookieSource + _ = settings.devinBearerToken + _ = settings.devinOrganization + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .devin(context.settings.devinSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.devinCookieSource.rawValue }, + set: { raw in + context.settings.devinCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.devinCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports the app.devin.ai session from Chrome.", + manual: "Paste an Authorization Bearer token from app.devin.ai.", + off: "Paste an Authorization Bearer token from app.devin.ai.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "devin-cookie-source", + title: "Auth source", + subtitle: "Automatically imports the app.devin.ai session from Chrome.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "devin-organization", + title: "Organization", + subtitle: "Optional. Use the slug from app.devin.ai/org/, or paste the full Devin org URL.", + kind: .plain, + placeholder: "org/example-org", + binding: context.stringBinding(\.devinOrganization), + actions: [ + ProviderSettingsActionDescriptor( + id: "devin-open-usage", + title: "Open Devin Usage", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(Self.usageURL(organization: context.settings.devinOrganization)) + }), + ], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "devin-bearer-token", + title: "Bearer token", + subtitle: "Paste the Authorization header value from app.devin.ai.", + kind: .secure, + placeholder: "Bearer eyJ...", + binding: context.stringBinding(\.devinBearerToken), + actions: [], + isVisible: { context.settings.devinCookieSource == .manual }, + onActivate: nil), + ] + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Open Devin...", .loginToProvider(url: Self.usageURL(organization: nil).absoluteString)) + } + + @MainActor + func runLoginFlow(context: ProviderLoginContext) async -> Bool { + let organization = context.controller.settings.devinOrganization + NSWorkspace.shared.open(Self.usageURL(organization: organization)) + return false + } + + private static func usageURL(organization: String?) -> URL { + let normalized = DevinUsageFetcher.normalizedOrganization(organization) + let urlString: String + if let normalized, normalized.hasPrefix("org/") { + let slug = String(normalized.dropFirst(4)) + urlString = "https://app.devin.ai/org/\(slug)/settings/usage" + } else { + urlString = "https://app.devin.ai/settings/usage" + } + return URL(string: urlString) ?? URL(string: "https://app.devin.ai")! + } +} diff --git a/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift new file mode 100644 index 000000000..430f44043 --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var devinBearerToken: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .devin, field: "cookieHeader", value: newValue) + } + } + + var devinCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .devin, fallback: .auto) } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .devin, field: "cookieSource", value: newValue.rawValue) + } + } + + var devinOrganization: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} + +extension SettingsStore { + func devinSettingsSnapshot(tokenOverride _: TokenAccountOverride?) -> ProviderSettingsSnapshot + .DevinProviderSettings { + ProviderSettingsSnapshot.DevinProviderSettings( + cookieSource: self.devinCookieSource, + manualBearerToken: self.devinBearerToken, + organization: self.devinOrganization) + } +} diff --git a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift index ed1f7129e..59e76dc72 100644 --- a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift +++ b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift @@ -35,7 +35,7 @@ extension UsageStore { self.kiloEnabledScopes.count > 1 } - func refreshKiloScopes() async { + func refreshKiloScopes(generation: UInt64? = nil) async { let scopes = self.kiloEnabledScopes guard scopes.count > 1 else { await MainActor.run { self.kiloScopeSnapshots = [] } @@ -102,6 +102,7 @@ extension UsageStore { let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(.kilo, generation: generation) else { return } self.kiloScopeSnapshots = ordered } } diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift index bcb9b68cf..361f2d519 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -39,7 +39,7 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.miMoCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + auto: "Automatic imports browser cookies from Xiaomi MiMo.", manual: "Paste a Cookie header from platform.xiaomimimo.com.", off: "Xiaomi MiMo cookies are disabled.") } @@ -48,7 +48,7 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "mimo-cookie-source", title: "Cookie source", - subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + subtitle: "Automatic imports browser cookies from Xiaomi MiMo.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index ef97d3f06..41d6102c8 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -26,6 +26,7 @@ enum ProviderImplementationRegistry { case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() case .copilot: CopilotProviderImplementation() + case .devin: DevinProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() case .manus: ManusProviderImplementation() diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 2741f091d..8e8e0ed9f 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -16,7 +16,8 @@ extension StatusItemController { let response = alert.runModal() if response == .alertFirstButtonReturn { - Self.openTerminalWithGcloudCommand() + self.openTerminal( + command: "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform") } // Refresh after user may have logged in @@ -26,23 +27,4 @@ extension StatusItemController { await self.store.refresh() } } - - private static func openTerminalWithGcloudCommand() { - let script = """ - tell application "Terminal" - activate - do script "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform" - end tell - """ - - if let appleScript = NSAppleScript(source: script) { - var error: NSDictionary? - appleScript.executeAndReturnError(&error) - if let error { - CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", - metadata: ["error": String(describing: error)]) - } - } - } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-devin.svg b/Sources/CodexBar/Resources/ProviderIcon-devin.svg new file mode 100644 index 000000000..e2b1cd5f6 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index b1e7a68a8..4c53d34b4 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Ucraïnès"; +"language_japanese" = "Japonès"; + "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el QuotaKit automàticament en iniciar el Mac."; @@ -1500,7 +1502,7 @@ "Auth source" = "Font d'autenticació"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes de Chrome de Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes del navegador de Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automàticament dades de sessió de Windsurf del localStorage de Chromium."; @@ -1700,6 +1702,8 @@ "Re-auth" = "Reautentica"; +"Re-login at claude.ai" = "Torna a iniciar sessió a claude.ai"; + "Re-authenticating…" = "S'està reautenticant…"; "Refresh Session" = "Actualitza la sessió"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 80d622620..a476a8fcd 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Українська"; +"language_japanese" = "Japanese"; + "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens QuotaKit when you start your Mac."; @@ -1831,7 +1833,7 @@ "Auth source" = "Auth source"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Automatic imports Chrome browser cookies from Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Automatic imports browser cookies from Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatic imports Windsurf session data from Chromium browser localStorage."; @@ -2031,6 +2033,8 @@ "Re-auth" = "Re-auth"; +"Re-login at claude.ai" = "Re-login at claude.ai"; + "Re-authenticating…" = "Re-authenticating…"; "Refresh Session" = "Refresh Session"; @@ -2143,3 +2147,9 @@ "language_vietnamese" = "Vietnamese"; + +"Request quota: %@ / %@" = "Request quota: %@ / %@"; + +"terminal_app_subtitle" = "Terminal used by the Open Terminal action"; + +"terminal_app_title" = "Default Terminal"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 4cae52265..30f5f9b58 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Ucraniano"; +"language_japanese" = "Japonés"; + "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre QuotaKit automáticamente al iniciar tu Mac."; @@ -1500,7 +1502,7 @@ "Auth source" = "Fuente de autenticación"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies de Chrome desde Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies del navegador desde Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automáticamente datos de sesión de Windsurf desde localStorage de Chromium."; @@ -1700,6 +1702,8 @@ "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Volver a iniciar sesión en claude.ai"; + "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Actualizar sesión"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 539fcf8da..e7e8abe96 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Ukrainien"; +"language_japanese" = "Japonais"; + "language_vietnamese" = "Vietnamien"; "start_at_login_title" = "Lancer à l'ouverture de session"; @@ -1786,7 +1788,7 @@ "Auth source" = "Source d'authentification"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur Chrome de Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur de Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importe automatiquement les données de session Windsurf à partir du navigateur Chromium localStorage."; @@ -1986,6 +1988,8 @@ "Re-auth" = "Se reconnecter"; +"Re-login at claude.ai" = "Se reconnecter à claude.ai"; + "Re-authenticating…" = "Réauthentification…"; "Refresh Session" = "Session de rafraîchissement"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings new file mode 100644 index 000000000..6d253e1ad --- /dev/null +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,1069 @@ +/* Japanese localization for CodexBar */ + +" providers" = " 件のプロバイダ"; +"(System)" = "(システム)"; +"30d" = "30日"; +"A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; +"API key" = "API キー"; +"API region" = "API リージョン"; +"API token" = "API トークン"; +"API tokens" = "API トークン"; +"About" = "このアプリについて"; +"Account" = "アカウント"; +"Accounts" = "アカウント"; +"Accounts subtitle" = "アカウントのサブタイトル"; +"Active" = "アクティブ"; +"Add" = "追加"; +"Add Workspace" = "ワークスペースを追加"; +"Advanced" = "詳細"; +"All" = "すべて"; +"Always allow prompts" = "常にプロンプトを許可"; +"Animation pattern" = "アニメーションパターン"; +"Antigravity login is managed in the app" = "Antigravity のログインはアプリ内で管理されます"; +"Applies only to the Security.framework OAuth keychain reader." = "Security.framework の OAuth キーチェーンリーダーにのみ適用されます。"; +"Auto falls back to the next source if the preferred one fails." = "自動では、優先ソースが失敗した場合に次のソースへフォールバックします。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動では、まず API を使用し、認証に失敗した場合は CLI にフォールバックします。"; +"Auto-detect" = "自動検出"; +"Auto-refresh is off; use the menu's Refresh command." = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; +"Auto-refresh: hourly · Timeout: 10m" = "自動更新: 1時間ごと · タイムアウト: 10分"; +"Automatic" = "自動"; +"Automatic imports browser cookies and WorkOS tokens." = "自動では、ブラウザの Cookie と WorkOS トークンを読み込みます。"; +"Automatic imports browser cookies and local storage tokens." = "自動では、ブラウザの Cookie とローカルストレージのトークンを読み込みます。"; +"Automatic imports browser cookies for dashboard extras." = "自動では、ダッシュボードの追加情報用にブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies for the web API." = "自動では、Web API 用にブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from Model Studio/Bailian." = "自動では、Model Studio/Bailian からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from admin.mistral.ai." = "自動では、admin.mistral.ai からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from opencode.ai." = "自動では、opencode.ai からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies or stored sessions." = "自動では、ブラウザの Cookie または保存済みセッションを読み込みます。"; +"Automatic imports browser cookies." = "自動では、ブラウザの Cookie を読み込みます。"; +"Automatically imports browser session cookie." = "ブラウザのセッション Cookie を自動的に読み込みます。"; +"Automatically opens CodexBar when you start your Mac." = "Mac の起動時に QuotaKit を自動的に開きます。"; +"Automation" = "オートメーション"; +"Average (\\(label1) + \\(label2))" = "平均 (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均 (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "キーチェーンのプロンプトを回避"; +"Balance" = "残高"; +"Battery Saver" = "バッテリーセーバー"; +"Bordered" = "枠線あり"; +"Build" = "ビルド"; +"Built \\(buildTimestamp)" = "ビルド日時 \\(buildTimestamp)"; +"Buy Credits..." = "クレジットを購入..."; +"Buy Credits…" = "クレジットを購入…"; +"CLI paths" = "CLI パス"; +"CLI sessions" = "CLI セッション"; +"Caches" = "キャッシュ"; +"Cancel" = "キャンセル"; +"Check for Updates…" = "アップデートを確認…"; +"Check for updates automatically" = "アップデートを自動的に確認"; +"Check if you like your agents having some fun up there." = "エージェントがメニューバーで楽しく動き回るのがお好みならチェックしてください。"; +"Check provider status" = "プロバイダの状態を確認"; +"Choose Codex workspace" = "Codex ワークスペースを選択"; +"Choose the MiniMax host (global .io or China mainland .com)." = "MiniMax のホストを選択します(グローバルの .io または中国本土の .com)。"; +"Choose up to " = "選択可能数: 最大 "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "最大 \\(Self.maxOverviewProviders) 件のプロバイダを選択"; +"Choose up to \\(count) providers" = "最大 \\(count) 件のプロバイダを選択"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "メニューバーに表示する内容を選択します(ペースは想定に対する使用量を表示します)。"; +"Choose which Codex account CodexBar should follow." = "QuotaKit が追跡する Codex アカウントを選択します。"; +"Choose which window drives the menu bar percent." = "メニューバーのパーセント表示に使用するウインドウを選択します。"; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI が見つかりません"; +"Claude binary" = "Claude バイナリ"; +"Claude cookies" = "Claude の Cookie"; +"Claude login failed" = "Claude のログインに失敗しました"; +"Claude login timed out" = "Claude のログインがタイムアウトしました"; +"Close" = "閉じる"; +"Code review" = "コードレビュー"; +"Codex CLI not found" = "Codex CLI が見つかりません"; +"Codex account login already running" = "Codex アカウントのログインがすでに実行中です"; +"Codex binary" = "Codex バイナリ"; +"Codex login failed" = "Codex のログインに失敗しました"; +"Codex login timed out" = "Codex のログインがタイムアウトしました"; +"CodexBar Lifecycle Keepalive" = "QuotaKit ライフサイクルキープアライブ"; +"CodexBar can't show its menu bar icon" = "QuotaKit はメニューバーアイコンを表示できません"; +"CodexBar could not read managed account storage. " = "QuotaKit は管理対象アカウントのストレージを読み取れませんでした。"; +"Configure…" = "設定…"; +"Connected" = "接続済み"; +"Controls how much detail is logged." = "記録するログの詳細度を制御します。"; +"Cookie header" = "Cookie ヘッダー"; +"Cookie source" = "Cookie ソース"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nまたは Abacus AI ダッシュボードからの cURL キャプチャを貼り付けてください"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nまたは __Secure-next-auth.session-token の値を貼り付けてください"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nまたは kimi-auth トークンの値を貼り付けてください"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "コスト"; +"Could not add Codex account" = "Codex アカウントを追加できませんでした"; +"Could not open Terminal for Gemini" = "Gemini 用のターミナルを開けませんでした"; +"Could not start claude /login" = "claude /login を開始できませんでした"; +"Could not start codex login" = "codex login を開始できませんでした"; +"Could not switch system account" = "システムアカウントを切り替えられませんでした"; +"Credits" = "クレジット"; +"Credits history" = "クレジット履歴"; +"Cursor login failed" = "Cursor のログインに失敗しました"; +"Custom" = "カスタム"; +"Custom Path" = "カスタムパス"; +"Daily Routines" = "デイリールーティン"; +"Debug" = "デバッグ"; +"Default" = "デフォルト"; +"Disable Keychain access" = "キーチェーンへのアクセスを無効にする"; +"Disabled" = "無効"; +"Dismiss" = "閉じる"; +"Disconnected" = "未接続"; +"Display" = "表示"; +"Display mode" = "表示モード"; +"Display reset times as absolute clock values instead of countdowns." = "リセット時刻をカウントダウンではなく絶対時刻で表示します。"; +"Done" = "完了"; +"Effective PATH" = "有効な PATH"; +"Email" = "メールアドレス"; +"Enable Merge Icons to configure Overview tab providers." = "「アイコンを統合」を有効にすると、概要タブのプロバイダを設定できます。"; +"Enable file logging" = "ファイルへのログ記録を有効にする"; +"Enabled" = "有効"; +"Error" = "エラー"; +"Error simulation" = "エラーシミュレーション"; +"Expose troubleshooting tools in the Debug tab." = "デバッグタブにトラブルシューティングツールを表示します。"; +"Failed" = "失敗"; +"False" = "False"; +"Fetch strategy attempts" = "取得戦略の試行"; +"Fetching" = "取得中"; +"Field" = "フィールド"; +"Field subtitle" = "フィールドのサブタイトル"; +"Finish the current managed account change before switching the system account." = "システムアカウントを切り替える前に、現在の管理対象アカウントの変更を完了してください。"; +"Force animation on next refresh" = "次回の更新時にアニメーションを強制実行"; +"Gateway region" = "ゲートウェイリージョン"; +"Gemini CLI not found" = "Gemini CLI が見つかりません"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity の障害情報をアイコンとメニューに表示します。"; +"General" = "一般"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot ログイン"; +"GitHub Login" = "GitHub ログイン"; +"Hide details" = "詳細を非表示"; +"Hide personal information" = "個人情報を非表示"; +"Historical tracking" = "履歴トラッキング"; +"How often CodexBar polls providers in the background." = "QuotaKit がバックグラウンドでプロバイダをポーリングする頻度です。"; +"Inactive" = "非アクティブ"; +"Install CLI" = "CLI をインストール"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Claude CLI をインストールして(npm i -g @anthropic-ai/claude-code)、もう一度お試しください。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Codex CLI をインストールして(npm i -g @openai/codex)、もう一度お試しください。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Gemini CLI をインストールして(npm i -g @google/gemini-cli)、もう一度お試しください。"; +"JetBrains AI is ready" = "JetBrains AI の準備ができました"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "CLI セッションを維持する"; +"Keyboard shortcut" = "キーボードショートカット"; +"Keychain access" = "キーチェーンへのアクセス"; +"Keychain prompt policy" = "キーチェーンのプロンプトポリシー"; +"Last \\(name) fetch failed:" = "前回の \\(name) の取得に失敗しました:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "前回の \\(self.store.metadata(for: self.provider).displayName) の取得に失敗しました:"; +"Last attempt" = "最終試行"; +"Link" = "リンク"; +"Loading animations" = "読み込み中アニメーション"; +"Loading…" = "読み込み中…"; +"Local" = "ローカル"; +"Logging" = "ログ"; +"Login failed" = "ログインに失敗しました"; +"Login shell PATH (startup capture)" = "ログインシェルの PATH(起動時に取得)"; +"Login timed out" = "ログインがタイムアウトしました"; +"MCP details" = "MCP の詳細"; +"Managed Codex accounts unavailable" = "管理対象の Codex アカウントを利用できません"; +"Managed account storage is unreadable. Live account access is still available, " = "管理対象アカウントのストレージを読み取れません。ライブアカウントへのアクセスは引き続き利用できます。"; +"Manual" = "手動"; +"May your tokens never run out—keep agent limits in view." = "トークンが尽きませんように — エージェントの上限を常に見守りましょう。"; +"Menu bar" = "メニューバー"; +"Menu bar auto-shows the provider closest to its rate limit." = "メニューバーには、レート制限に最も近いプロバイダが自動的に表示されます。"; +"Menu bar metric" = "メニューバーの指標"; +"Menu bar shows percent" = "メニューバーにパーセントを表示"; +"Menu content" = "メニューの内容"; +"Merge Icons" = "アイコンを統合"; +"Never prompt" = "プロンプトを表示しない"; +"No" = "いいえ"; +"No Codex accounts detected yet." = "Codex アカウントはまだ検出されていません。"; +"No JetBrains IDE detected" = "JetBrains IDE が検出されません"; +"No cost history data." = "コスト履歴データがありません。"; +"No data available" = "データがありません"; +"No data yet" = "まだデータがありません"; +"No enabled providers available for Overview." = "概要に表示できる有効なプロバイダがありません。"; +"No providers selected" = "プロバイダが選択されていません"; +"No token accounts yet." = "トークンアカウントはまだありません。"; +"No usage breakdown data." = "使用量の内訳データがありません。"; +"None" = "なし"; +"Notifications" = "通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "5時間セッションのクォータが 0% になったとき、および再び利用可能になったときに通知します "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "メニューバーとメニュー UI でメールアドレスを伏せ字にします。"; +"Off" = "オフ"; +"Offline" = "オフライン"; +"On" = "オン"; +"Online" = "オンライン"; +"Only on user action" = "ユーザー操作時のみ"; +"Open" = "開く"; +"Open API Keys" = "API キーを開く"; +"Open Amp Settings" = "Amp の設定を開く"; +"Open Antigravity to sign in, then refresh CodexBar." = "Antigravity を開いてサインインしてから、QuotaKit を更新してください。"; +"Open Browser" = "ブラウザを開く"; +"Open Coding Plan" = "コーディングプランを開く"; +"Open Console" = "コンソールを開く"; +"Open Dashboard" = "ダッシュボードを開く"; +"Open Mistral Admin" = "Mistral 管理画面を開く"; +"Open Menu Bar Settings" = "メニューバー設定を開く"; +"Open Ollama Settings" = "Ollama の設定を開く"; +"Open Terminal" = "ターミナルを開く"; +"Open Usage Page" = "使用状況ページを開く"; +"Open Warp API Key Guide" = "Warp API キーガイドを開く"; +"Open menu" = "メニューを開く"; +"Open token file" = "トークンファイルを開く"; +"OpenAI cookies" = "OpenAI の Cookie"; +"OpenAI web extras" = "OpenAI Web 追加情報"; +"Option A" = "オプション A"; +"Option B" = "オプション B"; +"Optional override if workspace lookup fails." = "ワークスペースの検索に失敗した場合の任意の上書き設定です。"; +"Options" = "オプション"; +"Override auto-detection with a custom IDE base path" = "カスタムの IDE ベースパスで自動検出を上書き"; +"Overview" = "概要"; +"Overview rows always follow provider order." = "概要の行は常にプロバイダの順序に従います。"; +"Overview tab providers" = "概要タブのプロバイダ"; +"Paste API key…" = "API キーを貼り付け…"; +"Paste API token…" = "API トークンを貼り付け…"; +"Paste key…" = "キーを貼り付け…"; +"Paste sessionKey or OAuth token…" = "sessionKey または OAuth トークンを貼り付け…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。"; +"Paste token…" = "トークンを貼り付け…"; +"Personal" = "個人"; +"Picker" = "ピッカー"; +"Picker subtitle" = "ピッカーのサブタイトル"; +"Placeholder" = "プレースホルダ"; +"Plan" = "プラン"; +"Play full-screen confetti when weekly usage resets." = "週間使用量がリセットされたときに全画面の紙吹雪を表示します。"; +"Polls OpenAI/Claude status pages and Google Workspace for " = "OpenAI/Claude のステータスページと Google Workspace をポーリングして、"; +"Prevents any Keychain access while enabled." = "有効にすると、キーチェーンへのアクセスをすべて防ぎます。"; +"Primary (API key limit)" = "プライマリ(API キー上限)"; +"Primary (\\(label))" = "プライマリ (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "プライマリ (\\(metadata.sessionLabel))"; +"Probe logs" = "プローブログ"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "プログレスバーは(残量表示ではなく)クォータの消費に応じて満たされていきます。"; +"Provider" = "プロバイダ"; +"Providers" = "プロバイダ"; +"Quit CodexBar" = "QuotaKit を終了"; +"Random (default)" = "ランダム(デフォルト)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "ローカルの使用ログを読み取り、今日のコストと選択した履歴期間のコストをメニューに表示します。"; +"Refresh" = "更新"; +"Refresh cadence" = "更新間隔"; +"Remote" = "リモート"; +"Remove" = "削除"; +"Remove Codex account?" = "Codex アカウントを削除しますか?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) を QuotaKit から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) を QuotaKit から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove selected account" = "選択したアカウントを削除"; +"Replace critter bars with provider branding icons and a percentage." = "クリッターバーをプロバイダのブランドアイコンとパーセント表示に置き換えます。"; +"Replay selected animation" = "選択したアニメーションを再生"; +"Requires authentication via GitHub Device Flow." = "GitHub Device Flow による認証が必要です。"; +"Resets: \\(reset)" = "リセット: \\(reset)"; +"Rolling five-hour limit" = "5時間のローリング上限"; +"Search hourly" = "1時間ごとに検索"; +"Secondary (\\(label))" = "セカンダリ (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "セカンダリ (\\(metadata.weeklyLabel))"; +"Select a provider" = "プロバイダを選択"; +"Select the IDE to monitor" = "監視する IDE を選択"; +"Session quota notifications" = "セッションクォータ通知"; +"Session tokens" = "セッショントークン"; +"Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Codex クレジットと Claude 追加使用量のセクションをメニューに表示します。"; +"Show Debug Settings" = "デバッグ設定を表示"; +"Show all token accounts" = "すべてのトークンアカウントを表示"; +"Show cost summary" = "コスト概要を表示"; +"Show credits + extra usage" = "クレジットと追加使用量を表示"; +"Show details" = "詳細を表示"; +"Show most-used provider" = "最も使用中のプロバイダを表示"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "切替バーにプロバイダのアイコンを表示します(オフの場合は週間進捗ラインを表示します)。"; +"Show reset time as clock" = "リセット時刻を時計表示"; +"Show usage as used" = "使用量を消費分で表示"; +"Sign in via button below" = "下のボタンからサインイン"; +"Skip teardown between probes (debug-only)." = "プローブ間のティアダウンをスキップします(デバッグ専用)。"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "メニューにトークンアカウントを積み重ねて表示します(オフの場合はアカウント切替バーを表示します)。"; +"Start at Login" = "ログイン時に起動"; +"Status" = "ステータス"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Claude の sessionKey Cookie または OAuth アクセストークンを保存します。"; +"Store multiple Abacus AI Cookie headers." = "複数の Abacus AI Cookie ヘッダーを保存します。"; +"Store multiple Augment Cookie headers." = "複数の Augment Cookie ヘッダーを保存します。"; +"Store multiple Cursor Cookie headers." = "複数の Cursor Cookie ヘッダーを保存します。"; +"Store multiple Factory Cookie headers." = "複数の Factory Cookie ヘッダーを保存します。"; +"Store multiple MiniMax Cookie headers." = "複数の MiniMax Cookie ヘッダーを保存します。"; +"Store multiple Mistral Cookie headers." = "複数の Mistral Cookie ヘッダーを保存します。"; +"Store multiple Ollama Cookie headers." = "複数の Ollama Cookie ヘッダーを保存します。"; +"Store multiple OpenCode Cookie headers." = "複数の OpenCode Cookie ヘッダーを保存します。"; +"Store multiple OpenCode Go Cookie headers." = "複数の OpenCode Go Cookie ヘッダーを保存します。"; +"Stored in the CodexBar config file." = "QuotaKit の設定ファイルに保存されます。"; +"Stored in ~/.codexbar/config.json. " = "~/.quotakit/config.json に保存されます。 "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "~/.quotakit/config.json に保存されます。kimi-k2.ai で生成できます。"; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "~/.quotakit/config.json に保存されます。Synthetic ダッシュボードのキーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "~/.quotakit/config.json に保存されます。Model Studio の Coding Plan API キーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "~/.quotakit/config.json に保存されます。MiniMax API キーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "~/.quotakit/config.json に保存されます。KILO_API_KEY を指定することもできます。または "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "ローカルの Codex 使用履歴(8 週間分)を保存して、ペース予測をパーソナライズします。"; +"Subscription Utilization" = "サブスクリプション利用率"; +"Surprise me" = "サプライズ"; +"Switcher shows icons" = "切替バーにアイコンを表示"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "QuotaKitCLI を quotakit として /usr/local/bin と /opt/homebrew/bin にシンボリックリンクします。"; +"System" = "システム"; +"Temporarily shows the loading animation after the next refresh." = "次回の更新後に読み込みアニメーションを一時的に表示します。"; +"Tertiary (\\(label))" = "第 3(\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "第 3(\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "この Mac のデフォルトの Codex アカウントです。"; +"Toggle" = "切り替え"; +"Toggle subtitle" = "サブタイトルを切り替え"; +"Token" = "トークン"; +"Trigger the menu bar menu from anywhere." = "どこからでもメニューバーのメニューを開きます。"; +"True" = "True"; +"Twitter" = "Twitter"; +"Unsupported" = "未対応"; +"Update Channel" = "アップデートチャンネル"; +"Updated" = "更新済み"; +"Updates unavailable in this build." = "このビルドではアップデートを利用できません。"; +"Usage" = "使用量"; +"Usage breakdown" = "使用量の内訳"; +"Usage history (30 days)" = "使用履歴"; +"Usage source" = "使用量の取得元"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中国本土向けエンドポイント(open.bigmodel.cn)には BigModel を使用します。"; +"Use a single menu bar icon with a provider switcher." = "プロバイダ切替付きの単一のメニューバーアイコンを使用します。"; +"Use international or China mainland console gateways for quota fetches." = "クォータ取得に国際版または中国本土版のコンソールゲートウェイを使用します。"; +"Version" = "バージョン"; +"Version \\(self.versionString)" = "バージョン \\(self.versionString)"; +"Version \\(version)" = "バージョン \\(version)"; +"Version \\(versionString)" = "バージョン \\(versionString)"; +"Vertex AI Login" = "Vertex AI ログイン"; +"Wait for the current managed Codex login to finish before adding another account." = "別のアカウントを追加する前に、現在のマネージド Codex ログインが完了するまでお待ちください。"; +"Waiting for Authentication..." = "認証を待機中..."; +"Website" = "Web サイト"; +"Weekly limit confetti" = "週間上限の紙吹雪"; +"Weekly token limit" = "週間トークン上限"; +"Weekly usage" = "週間使用量"; +"Weekly usage unavailable for this account." = "このアカウントでは週間使用量を取得できません。"; +"Window: \\(window)" = "ウインドウ: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "デバッグ用にログを \\(self.fileLogPath) に書き込みます。"; +"Yes" = "はい"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30日 \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): 取得中…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): 最終試行 \\(when)"; +"\\(name): no data yet" = "\\(name): データなし"; +"\\(name): unsupported" = "\\(name): 未対応"; +"all browsers" = "すべてのブラウザ"; +"available again." = "再び利用可能になりました。"; +"built_format" = "ビルド: %@"; +"copilot_complete_in_browser" = "ブラウザでサインインを完了してください。"; +"copilot_device_code" = "デバイスコードをクリップボードにコピーしました: %1$@\n\n確認先: %2$@"; +"copilot_device_code_copied" = "デバイスコードをコピーしました。"; +"copilot_verify_at" = "%@ で確認してください"; +"copilot_waiting_text" = "ブラウザでサインインを完了してください。\nサインインが完了すると、このウインドウは自動的に閉じます。"; +"copilot_window_closes_auto" = "サインインが完了すると、このウインドウは自動的に閉じます。"; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: 取得中… %2$@"; +"cost_status_last_attempt" = "%1$@: 最終試行 %2$@"; +"cost_status_no_data" = "%@: データなし"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: 未対応"; +"credits_remaining" = "クレジット: %@"; +"cursor_on_demand" = "オンデマンド: %@"; +"cursor_on_demand_with_limit" = "オンデマンド: %1$@ / %2$@"; +"extra_usage_format" = "追加使用量: %1$@ / %2$@"; +"jetbrains_detected_generate" = "検出: %@。AI アシスタントを一度使用してクォータデータを生成してから、QuotaKit を更新してください。"; +"jetbrains_detected_select" = "検出: %@。設定でお使いの IDE を選択してから、QuotaKit を更新してください。"; +"last_fetch_failed_with_provider" = "前回の %@ の取得に失敗しました:"; +"last_spend" = "直近の支出: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "リセット: %@"; +"mcp_window" = "ウインドウ: %@"; +"metric_average" = "平均(%1$@ + %2$@)"; +"metric_primary" = "プライマリ(%@)"; +"metric_secondary" = "セカンダリ(%@)"; +"metric_tertiary" = "第 3(%@)"; +"multiple_workspaces_found" = "QuotaKit は %@ の複数のワークスペースを見つけました。追加するワークスペースを選択してください。"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "最大 %@ 個のプロバイダを選択"; +"remove_account_message" = "%@ を QuotaKit から削除しますか?マネージド Codex ホームも削除されます。"; +"version_format" = "バージョン %@"; +"vertex_ai_login_instructions" = "Vertex AI の使用状況を追跡するには、Google Cloud で認証してください。\n\n1. ターミナルを開く\n2. 実行: gcloud auth application-default login\n3. ブラウザの指示に従ってサインイン\n4. プロジェクトを設定: gcloud config set project PROJECT_ID\n\n今すぐターミナルを開きますか?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID が設定されていますが、workspaceID に対応しているのは opencode、opencodego、deepgram のみです。"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + +/* General Pane */ +"section_system" = "システム"; +"section_usage" = "使用量"; +"section_automation" = "自動化"; +"language_title" = "言語"; +"language_subtitle" = "表示言語を変更します。完全に反映するにはアプリの再起動が必要です。"; +"language_system" = "システム"; +"language_english" = "英語"; +"language_spanish" = "スペイン語"; +"language_catalan" = "カタロニア語"; +"language_chinese_simplified" = "簡体字中国語"; +"language_chinese_traditional" = "繁体字中国語"; +"language_portuguese_brazilian" = "ポルトガル語(ブラジル)"; +"language_dutch" = "オランダ語"; +"language_swedish" = "スウェーデン語"; +"language_french" = "フランス語"; +"language_ukrainian" = "ウクライナ語"; +"language_japanese" = "日本語"; +"start_at_login_title" = "ログイン時に起動"; +"start_at_login_subtitle" = "Mac の起動時に QuotaKit を自動的に開きます。"; +"show_cost_summary" = "コスト概要を表示"; +"show_cost_summary_subtitle" = "ローカルの使用ログを読み取り、今日分と選択した履歴期間をメニューに表示します。"; +"cost_history_days_title" = "履歴期間: %d 日"; +"cost_auto_refresh_info" = "自動更新: 1 時間ごと · タイムアウト: 10 分"; +"refresh_cadence_title" = "更新間隔"; +"refresh_cadence_subtitle" = "QuotaKit がバックグラウンドでプロバイダをポーリングする頻度です。"; +"manual_refresh_hint" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; +"check_provider_status_title" = "プロバイダのステータスを確認"; +"check_provider_status_subtitle" = "OpenAI/Claude のステータスページと Gemini/Antigravity 用の Google Workspace をポーリングし、障害情報をアイコンとメニューに表示します。"; +"session_quota_notifications_title" = "セッションクォータ通知"; +"session_quota_notifications_subtitle" = "5 時間のセッションクォータが 0% になったとき、および再び利用可能になったときに通知します。"; +"quota_warning_notifications_title" = "クォータ警告通知"; +"quota_warning_notifications_subtitle" = "セッションまたは週間クォータの残量が設定したしきい値を下回ったときに警告します。"; +"quota_warnings_title" = "クォータ警告"; +"quota_warning_session" = "セッション"; +"quota_warning_session_capitalized" = "セッション"; +"quota_warning_weekly" = "週間"; +"quota_warning_weekly_capitalized" = "週間"; +"quota_warning_notification_title" = "%1$@ の%2$@クォータが残りわずか"; +"quota_warning_notification_body" = "残り %1$@。設定した %2$d%% の%3$@警告しきい値に達しました。"; +"quota_warning_notification_body_with_account" = "アカウント %1$@。残り %2$@。設定した %3$d%% の%4$@警告しきい値に達しました。"; +"session_depleted_notification_title" = "%@ のセッションを使い切りました"; +"session_depleted_notification_body" = "残り 0% です。再び利用可能になったら通知します。"; +"session_restored_notification_title" = "%@ のセッションが回復しました"; +"session_restored_notification_body" = "セッションクォータが再び利用可能になりました。"; +"quota_warning_warn_at" = "警告する残量"; +"quota_warning_global_threshold_subtitle" = "プロバイダ側で上書きされない限り、セッションおよび週間ウインドウの残量パーセントに適用されます。"; +"quota_warning_sound" = "通知音を再生"; +"quota_warning_provider_inherits" = "ここでウインドウをカスタマイズしない限り、グローバルのクォータ警告設定を使用します。"; +"quota_warning_customize_thresholds" = "%@ のしきい値をカスタマイズ"; +"quota_warning_enable_warnings" = "%@ の警告を有効にする"; +"quota_warning_window_warn_at" = "%@ の警告残量"; +"quota_warning_off" = "オフ"; +"quota_warning_inherited" = "継承: %@"; +"quota_warning_depleted_only" = "枯渇時のみ"; +"quota_warning_upper" = "上限"; +"quota_warning_lower" = "下限"; +"apply" = "適用"; +"quit_app" = "QuotaKit を終了"; + +/* Tab titles */ +"tab_general" = "一般"; +"tab_providers" = "プロバイダ"; +"tab_display" = "表示"; +"tab_advanced" = "詳細"; +"tab_about" = "情報"; +"tab_debug" = "デバッグ"; + +/* Providers Pane */ +"select_a_provider" = "プロバイダを選択"; +"cancel" = "キャンセル"; +"last_fetch_failed" = "前回の取得に失敗"; +"usage_not_fetched_yet" = "使用量は未取得"; +"managed_account_storage_unreadable" = "マネージドアカウントのストレージを読み取れません。ライブアカウントへのアクセスは引き続き可能ですが、ストアが復旧するまで、マネージドアカウントの追加・再認証・削除操作は無効になります。"; +"remove_codex_account_title" = "Codex アカウントを削除しますか?"; +"remove" = "削除"; +"managed_login_already_running" = "マネージド Codex ログインがすでに実行中です。別のアカウントを追加または再認証する前に、完了するまでお待ちください。"; +"managed_login_failed" = "マネージド Codex ログインが完了しませんでした。ターミナルで `codex --version` が動作することを確認してください。macOS が `codex` をブロックした、またはゴミ箱に移動した場合は、古い重複インストールを削除し、`npm install -g --include=optional @openai/codex@latest` を実行してから再試行してください。"; +"codex_login_output" = "codex login の出力:"; +"managed_login_missing_email" = "Codex ログインは完了しましたが、アカウントのメールアドレスを取得できませんでした。アカウントが完全にサインインしていることを確認してから、再試行してください。"; +"login_success_notification_title" = "%@ のログインに成功しました"; +"login_success_notification_body" = "アプリに戻れます。認証が完了しました。"; +"workspace_selection_cancelled" = "QuotaKit は複数のワークスペースを見つけましたが、ワークスペースが選択されませんでした。"; +"unsafe_managed_home" = "QuotaKit は想定外のマネージドホームパスの変更を拒否しました: %@"; +"menu_bar_metric_title" = "メニューバーの指標"; +"menu_bar_metric_subtitle" = "メニューバーのパーセント表示に使用するウインドウを選択します。"; +"menu_bar_metric_subtitle_deepseek" = "DeepSeek の残高をメニューバーに表示します。"; +"menu_bar_metric_subtitle_moonshot" = "Moonshot / Kimi API の残高をメニューバーに表示します。"; +"menu_bar_metric_subtitle_mistral" = "今月の Mistral API 支出をメニューバーに表示します。"; +"menu_bar_metric_subtitle_kimik2" = "Kimi K2 API キーのクレジットをメニューバーに表示します。"; +"automatic" = "自動"; +"primary_api_key_limit" = "プライマリ(API キー上限)"; + +/* Display Pane */ +"section_menu_bar" = "メニューバー"; +"merge_icons_title" = "アイコンを統合"; +"merge_icons_subtitle" = "プロバイダ切替付きの単一のメニューバーアイコンを使用します。"; +"switcher_shows_icons_title" = "切替バーにアイコンを表示"; +"switcher_shows_icons_subtitle" = "切替バーにプロバイダのアイコンを表示します(オフの場合は週間進捗ラインを表示します)。"; +"show_most_used_provider_title" = "最も使用中のプロバイダを表示"; +"show_most_used_provider_subtitle" = "レート制限に最も近いプロバイダをメニューバーに自動表示します。"; +"menu_bar_shows_percent_title" = "メニューバーにパーセントを表示"; +"menu_bar_shows_percent_subtitle" = "クリッターバーをプロバイダのブランドアイコンとパーセント表示に置き換えます。"; +"display_mode_title" = "表示モード"; +"display_mode_subtitle" = "メニューバーに表示する内容を選択します(ペースは使用量と想定値の比較を表示します)。"; +"section_menu_content" = "メニューの内容"; +"show_usage_as_used_title" = "使用量を消費分で表示"; +"show_usage_as_used_subtitle" = "プログレスバーが(残量ではなく)クォータの消費に応じて増えていきます。"; +"show_quota_warning_markers_title" = "クォータ警告マーカーを表示"; +"show_quota_warning_markers_subtitle" = "クォータ警告が設定されている場合、使用量バーにしきい値の目盛りを描画します。"; +"weekly_progress_work_days_title" = "週間進捗の作業日"; +"weekly_progress_work_days_subtitle" = "週間使用量バーに日付の区切り目盛りを描画します。"; +"show_reset_time_as_clock_title" = "リセット時刻を時計表示"; +"show_reset_time_as_clock_subtitle" = "リセット時刻をカウントダウンではなく絶対時刻で表示します。"; +"show_provider_changelog_links_title" = "プロバイダの変更履歴リンクを表示"; +"show_provider_changelog_links_subtitle" = "対応する CLI ベースのプロバイダのリリースノートへのリンクをメニューに追加します。"; +"show_credits_extra_usage_title" = "クレジットと追加使用量を表示"; +"show_credits_extra_usage_subtitle" = "Codex クレジットと Claude 追加使用量のセクションをメニューに表示します。"; +"show_all_token_accounts_title" = "すべてのトークンアカウントを表示"; +"show_all_token_accounts_subtitle" = "メニューにトークンアカウントを積み重ねて表示します(オフの場合はアカウント切替バーを表示します)。"; +"multi_account_layout_title" = "複数アカウントのレイアウト"; +"multi_account_layout_subtitle" = "セグメント式のアカウント切替か、積み重ね式のアカウントカードを選択します。"; +"multi_account_layout_segmented" = "セグメント"; +"multi_account_layout_stacked" = "スタック"; +"overview_tab_providers_title" = "概要タブのプロバイダ"; +"configure" = "設定…"; +"overview_enable_merge_icons_hint" = "概要タブのプロバイダを設定するには「アイコンを統合」を有効にしてください。"; +"overview_no_providers_hint" = "概要に使用できる有効なプロバイダがありません。"; +"overview_rows_follow_order" = "概要の行は常にプロバイダの並び順に従います。"; +"overview_no_providers_selected" = "プロバイダが選択されていません"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "キーボードショートカット"; +"open_menu_shortcut_title" = "メニューを開く"; +"open_menu_shortcut_subtitle" = "どこからでもメニューバーのメニューを開きます。"; +"install_cli" = "CLI をインストール"; +"install_cli_subtitle" = "QuotaKitCLI を quotakit として /usr/local/bin と /opt/homebrew/bin にシンボリックリンクします。"; +"cli_not_found" = "アプリバンドル内に QuotaKitCLI が見つかりません。"; +"no_writable_bin_dirs" = "書き込み可能な bin ディレクトリが見つかりません。"; +"show_debug_settings_title" = "デバッグ設定を表示"; +"show_debug_settings_subtitle" = "デバッグタブにトラブルシューティングツールを表示します。"; +"surprise_me_title" = "サプライズ"; +"surprise_me_subtitle" = "エージェントたちがメニューバーで少し遊ぶのが好きか試してみてください。"; +"weekly_limit_confetti_title" = "週間上限の紙吹雪"; +"weekly_limit_confetti_subtitle" = "週間使用量がリセットされたときに全画面の紙吹雪を再生します。"; +"hide_personal_info_title" = "個人情報を隠す"; +"hide_personal_info_subtitle" = "メニューバーとメニュー UI のメールアドレスを伏せ字にします。"; +"show_provider_storage_usage_title" = "プロバイダのストレージ使用量を表示"; +"show_provider_storage_usage_subtitle" = "ローカルディスクの使用量をメニューに表示します。既知のプロバイダ所有パスをバックグラウンドでスキャンします。"; +"section_keychain_access" = "キーチェーンアクセス"; +"keychain_access_caption" = "キーチェーンの読み書きをすべて無効にします。「常に許可」をクリックしても macOS が「Chrome/Brave/Edge Safe Storage」のプロンプトを表示し続ける場合に使用してください。有効中はブラウザの Cookie 読み込みが利用できないため、プロバイダで Cookie ヘッダーを手動で貼り付けてください。CLI 経由の Claude/Codex OAuth は引き続き動作します。"; +"disable_keychain_access_title" = "キーチェーンアクセスを無効にする"; +"disable_keychain_access_subtitle" = "有効中はキーチェーンへのアクセスを一切行いません。"; + +/* About Pane */ +"about_tagline" = "トークンが尽きませんように—エージェントの上限を常に見守りましょう。"; +"link_github" = "GitHub"; +"link_website" = "Webサイト"; +"link_twitter" = "Twitter"; +"link_email" = "メール"; +"check_updates_auto" = "アップデートを自動的に確認"; +"update_channel" = "アップデートチャンネル"; +"check_for_updates" = "アップデートを確認…"; +"updates_unavailable" = "このビルドではアップデートを利用できません。"; +"copyright" = "© 2026 Peter Steinberger. MIT License."; + +/* Debug Pane */ +"section_logging" = "ログ"; +"enable_file_logging" = "ファイルログを有効にする"; +"enable_file_logging_subtitle" = "デバッグ用に %@ へログを書き込みます。"; +"verbosity_title" = "詳細度"; +"verbosity_subtitle" = "ログに記録する詳細の量を制御します。"; +"open_log_file" = "ログファイルを開く"; +"force_animation_next_refresh" = "次回の更新時にアニメーションを強制する"; +"force_animation_next_refresh_subtitle" = "次回の更新後に読み込みアニメーションを一時的に表示します。"; +"section_loading_animations" = "読み込みアニメーション"; +"loading_animations_caption" = "パターンを選んでメニューバーで再生できます。\"ランダム\"は既存の動作を維持します。"; +"animation_random_default" = "ランダム(デフォルト)"; +"replay_selected_animation" = "選択したアニメーションを再生"; +"blink_now" = "今すぐ点滅"; +"section_probe_logs" = "プローブログ"; +"probe_logs_caption" = "デバッグ用に最新のプローブ出力を取得します。コピーでは全文が保持されます。"; +"fetch_log" = "ログを取得"; +"copy" = "コピー"; +"save_to_file" = "ファイルに保存"; +"load_parse_dump" = "解析ダンプを読み込む"; +"rerun_provider_autodetect" = "プロバイダの自動検出を再実行"; +"loading" = "読み込み中…"; +"no_log_yet_fetch" = "ログはまだありません。取得して読み込んでください。"; +"section_fetch_strategy" = "取得戦略の試行"; +"fetch_strategy_caption" = "プロバイダに対する直近の取得パイプラインの判断とエラーです。"; +"section_openai_cookies" = "OpenAI Cookie"; +"openai_cookies_caption" = "前回の OpenAI Cookie 試行における Cookie インポートと WebKit スクレイピングのログです。"; +"no_log_yet" = "ログはまだありません。プロバイダ → Codex で OpenAI Cookie を更新するとインポートが実行されます。"; +"section_caches" = "キャッシュ"; +"caches_caption" = "キャッシュされたコストスキャン結果またはブラウザの Cookie キャッシュを消去します。"; +"clear_cookie_cache" = "Cookie キャッシュを消去"; +"clear_cost_cache" = "コストキャッシュを消去"; +"section_notifications" = "通知"; +"notifications_caption" = "5時間セッション枠(枯渇/回復)のテスト通知を発行します。"; +"post_depleted" = "枯渇通知を送信"; +"post_restored" = "回復通知を送信"; +"section_cli_sessions" = "CLI セッション"; +"cli_sessions_caption" = "プローブ後も Codex/Claude の CLI セッションを維持します。デフォルトではデータ取得後に終了します。"; +"keep_cli_sessions_alive" = "CLI セッションを維持する"; +"keep_cli_sessions_alive_subtitle" = "プローブ間のクリーンアップをスキップします(デバッグ専用)。"; +"reset_cli_sessions" = "CLI セッションをリセット"; +"section_error_simulation" = "エラーシミュレーション"; +"error_simulation_caption" = "レイアウトテスト用に、メニューカードへ偽のエラーメッセージを挿入します。"; +"set_menu_error" = "メニューエラーを設定"; +"clear_menu_error" = "メニューエラーを消去"; +"set_cost_error" = "コストエラーを設定"; +"clear_cost_error" = "コストエラーを消去"; +"section_cli_paths" = "CLI パス"; +"cli_paths_caption" = "解決された Codex バイナリと PATH レイヤー、起動時のログインシェル PATH 取得(短いタイムアウト)です。"; +"codex_binary" = "Codex バイナリ"; +"claude_binary" = "Claude バイナリ"; +"effective_path" = "有効な PATH"; +"unavailable" = "利用不可"; +"login_shell_path" = "ログインシェル PATH(起動時に取得)"; +"cleared" = "消去しました。"; +"no_fetch_attempts" = "取得の試行はまだありません。"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe では、システム設定 → メニューバー → メニューバーに表示を許可 でメニューバーアプリがブロックされることがあります。QuotaKit は実行中ですが、macOS がアイコンを非表示にしている可能性があります。メニューバー設定を開き、QuotaKit をオンにしてください。"; + +/* Metric preferences */ +"metric_pref_automatic" = "自動"; +"metric_pref_primary" = "プライマリ"; +"metric_pref_secondary" = "セカンダリ"; +"metric_pref_tertiary" = "ターシャリ"; +"metric_pref_extra_usage" = "追加使用量"; +"metric_pref_average" = "平均"; + +/* Display modes */ +"display_mode_percent" = "パーセント"; +"display_mode_pace" = "ペース"; +"display_mode_both" = "両方"; +"display_mode_percent_desc" = "残り/使用済みのパーセンテージを表示(例: 45%)"; +"display_mode_pace_desc" = "ペースインジケータを表示(例: +5%)"; +"display_mode_both_desc" = "パーセンテージとペースの両方を表示(例: 45% · +5%)"; + +/* Provider status */ +"status_operational" = "正常稼働中"; +"status_partial_outage" = "一部障害"; +"status_major_outage" = "重大な障害"; +"status_critical_issue" = "致命的な問題"; +"status_maintenance" = "メンテナンス中"; +"status_unknown" = "ステータス不明"; + +/* Refresh frequency */ +"refresh_manual" = "手動"; +"refresh_1min" = "1分"; +"refresh_2min" = "2分"; +"refresh_5min" = "5分"; +"refresh_15min" = "15分"; +"refresh_30min" = "30分"; + +/* Additional keys */ +"not_found" = "見つかりません"; + +/* Cost estimation */ +"cost_header_estimated" = "コスト(推定)"; +"cost_estimate_hint" = "ローカルログからの推定値 · 請求額と異なる場合があります"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "AI Assistant 対応の JetBrains IDE が検出されませんでした。JetBrains IDE をインストールし、AI Assistant を有効にしてください。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API トークンが設定されていません。環境変数 OPENROUTER_API_KEY を設定するか、設定で構成してください。"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API トークンが見つかりません。~/.quotakit/config.json の apiKey または Z_AI_API_KEY を設定してください。"; +"Missing DeepSeek API key." = "DeepSeek API キーがありません。"; +"%@ is unavailable in the current environment." = "%@ は現在の環境では利用できません。"; +"All Systems Operational" = "全システム正常稼働中"; +"Last 30 days" = "過去30日間"; +"Last 30 days:" = "過去30日間:"; +"This month" = "今月"; +"Store multiple OpenAI API keys." = "複数の OpenAI API キーを保存します。"; +"Admin API key" = "管理者 API キー"; +"Open billing" = "請求情報を開く"; +"Google accounts" = "Google アカウント"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "複数の Antigravity Google OAuth アカウントを保存して素早く切り替えられます。"; +"Add Google Account" = "Google アカウントを追加"; +"Open Token Plan" = "トークンプランを開く"; +"Text Generation" = "テキスト生成"; +"Text to Speech" = "音声合成"; +"Music Generation" = "音楽生成"; +"Image Generation" = "画像生成"; +"No local data found" = "ローカルデータが見つかりません"; +"Credits unavailable; keep Codex running to refresh." = "クレジット情報を取得できません。更新するには Codex を実行したままにしてください。"; +"No available fetch strategy for minimax." = "minimax に利用可能な取得戦略がありません。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Cursor のセッションが見つかりません。Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX、または Edge Canary で cursor.com にログインしてください。Safari をお使いの場合は、システム設定 ▸ プライバシーとセキュリティ で QuotaKit にフルディスクアクセスを許可してください。QuotaKit のメニューから Cursor にサインインすることもできます(アカウントを追加/切り替え)。"; +"No OpenCode session cookies found in browsers." = "ブラウザに OpenCode のセッション Cookie が見つかりません。"; +"No available fetch strategy for %@." = "%@ に利用可能な取得戦略がありません。"; +"Today" = "今日"; +"Today tokens" = "今日のトークン"; +"30d cost" = "30日間のコスト"; +"30d tokens" = "30日間のトークン"; +"Latest tokens" = "最新のトークン"; +"Top model" = "最多使用モデル"; +"Storage" = "ストレージ"; +"Add Account..." = "アカウントを追加..."; +"Usage Dashboard" = "使用状況ダッシュボード"; +"Status Page" = "ステータスページ"; +"Settings..." = "設定..."; +"About CodexBar" = "QuotaKit について"; +"Quit" = "終了"; +"Last %d day" = "過去%d日間"; +"Last %d days" = "過去%d日間"; +"%@ tokens" = "%@ トークン"; +"Latest billing day" = "直近の請求日"; +"Latest billing day (%@)" = "直近の請求日(%@)"; +"%@ left" = "残り %@"; +"Resets %@" = "%@ にリセット"; +"Resets in %@" = "%@ 後にリセット"; +"Resets now" = "まもなくリセット"; +"Lasts until reset" = "リセットまで持続"; +"Updated %@" = "%@ に更新"; +"Updated %@h ago" = "%@時間前に更新"; +"Updated %@m ago" = "%@分前に更新"; +"Updated just now" = "たった今更新"; +"Projected empty in %@" = "%@ 後に枯渇する見込み"; +"Runs out in %@" = "%@ 後に使い切る見込み"; +"Pace: %@" = "ペース: %@"; +"Pace: %@ · %@" = "ペース: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "枯渇リスク ≈ %d%%"; +"%d%% in deficit" = "%d%% 不足"; +"%d%% in reserve" = "%d%% 余裕"; +"usage_percent_suffix_left" = "残り"; +"usage_percent_suffix_used" = "使用済み"; +"Store multiple DeepSeek API keys." = "複数の DeepSeek API キーを保存します。"; +"This week" = "今週"; +"Week" = "週"; +"Month" = "月"; +"Models" = "モデル"; +"24h tokens" = "24時間のトークン"; +"Latest hour" = "直近1時間"; +"Peak hour" = "ピーク時間帯"; +"Top method" = "最多使用メソッド"; +"30d cash" = "30日間の支出"; +"30d billing history from MiniMax web session" = "MiniMax Web セッションからの30日間の請求履歴"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer の請求情報は反映が遅れることがあります。"; +"Rate limit: %d / %@" = "レート制限: %d / %@"; +"Key remaining" = "キーの残量"; +"No limit set for the API key" = "API キーに上限が設定されていません"; +"API key limit unavailable right now" = "API キーの上限は現在取得できません"; +"This month: %@ tokens" = "今月: %@ トークン"; +"No utilization data yet." = "使用率データはまだありません。"; +"No %@ utilization data yet." = "%@ の使用率データはまだありません。"; +"%@: %@%% used" = "%@: %@%% 使用済み"; +"%dd" = "%d日"; +"today" = "今日"; +"just now" = "たった今"; +"On pace" = "想定ペース"; +"Runs out now" = "まもなく使い切ります"; +"Projected empty now" = "まもなく枯渇する見込み"; +"Switch Account..." = "アカウントを切り替え..."; +"Update ready, restart now?" = "アップデートの準備ができました。今すぐ再起動しますか?"; +"Daily" = "日別"; +"Hourly Tokens" = "時間別トークン"; +"No data" = "データなし"; +"No usage breakdown data available." = "使用状況の内訳データがありません。"; + +"Today: %@ · %@ tokens" = "今日: %@ · %@ トークン"; +"Today: %@" = "今日: %@"; +"Today: %@ tokens" = "今日: %@ トークン"; +"Last 30 days: %@ · %@ tokens" = "過去30日間: %@ · %@ トークン"; +"Last 30 days: %@" = "過去30日間: %@"; +"Est. total (30d): %@" = "推定合計(30日間): %@"; +"Est. total (%@): %@" = "推定合計(%@): %@"; +"Hover a bar for details" = "バーにポインタを合わせると詳細が表示されます"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ トークン"; +"No providers selected for Overview." = "概要に表示するプロバイダが選択されていません。"; +"No overview data available." = "概要データがありません。"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自動では、まずローカルの IDE API を使用し、IDE が閉じている場合は Google OAuth を使用します。"; +"Login with Google" = "Google でログイン"; + +/* Popup panels */ +"No usage configured." = "使用状況が設定されていません。"; +"Quota" = "クォータ"; +"tokens" = "トークン"; +"requests" = "リクエスト"; +"Latest" = "最新"; +"Monthly" = "月間"; +"Sonnet" = "Sonnet"; +"Overages" = "超過分"; +"Activity" = "アクティビティ"; +"Copied" = "コピーしました"; +"Copy error" = "エラーをコピー"; +"Copy path" = "パスをコピー"; +"Extra usage spent" = "追加使用分の支出"; +"Credits remaining" = "残りクレジット"; +"Using CLI fallback" = "CLI フォールバックを使用中"; +"Balance updates in near-real time (up to 5 min lag)" = "残高はほぼリアルタイムで更新されます(最大5分の遅延)"; +"Daily billing data finalizes at 07:00 UTC" = "日次請求データは 07:00 UTC に確定します"; +"%@ of %@ credits left" = "クレジット残り %@ / %@"; +"%@ of %@ bonus credits left" = "ボーナスクレジット残り %@ / %@"; +"%@ / %@ (%@ remaining)" = "%@ / %@(残り %@)"; +"%@/%@ left" = "残り %@/%@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@ に再生成"; +"used after next regen" = "次回再生成後の使用率"; +"after next regen" = "次回再生成後"; +"Near full" = "ほぼ満タン"; +"Full in ~1 regen" = "約1回の再生成で満タン"; +"Full in ~%.0f regens" = "約%.0f回の再生成で満タン"; +"Overage usage" = "超過使用量"; +"Overage cost" = "超過コスト"; +"credits" = "クレジット"; +"Zen balance" = "Zen 残高"; +"API spend" = "API 支出"; +"Extra usage" = "追加使用量"; +"Quota usage" = "クォータ使用量"; +"%.0f%% used" = "%.0f%% 使用済み"; +"Usage history (today)" = "使用履歴(今日)"; +"Usage history (%d days)" = "使用履歴(%d日間)"; +"%d percent remaining" = "残り %d パーセント"; +"Unknown" = "不明"; +"stale data" = "古いデータ"; +"No credits history data." = "クレジット履歴データがありません。"; +"No credits history data available." = "利用可能なクレジット履歴データがありません。"; +"Credits history chart" = "クレジット履歴チャート"; +"%d days of credits data" = "%d日間のクレジットデータ"; +"Usage breakdown chart" = "使用状況の内訳チャート"; +"%d days of usage data across %d services" = "%2$dサービスにわたる%1$d日間の使用状況データ"; +"Cost history chart" = "コスト履歴チャート"; +"%d days of cost data" = "%d日間のコストデータ"; +"Plan utilization chart" = "プラン使用率チャート"; +"%d utilization samples" = "%d 件の使用率サンプル"; +"Hourly Usage" = "時間別使用量"; +"Usage remaining" = "残りの使用量"; +"Usage used" = "使用済みの使用量"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "APIキーを確認しました。OllamaはAPI経由でCloudクォータ上限を公開していません。"; +"Last 30 days: %@ tokens" = "過去30日間: %@ トークン"; +"7d spend" = "7日間の支出"; +"30d spend" = "30日間の支出"; +"Cache read" = "キャッシュ読み取り"; +"Claude Admin API 30 day spend trend" = "Claude Admin API の30日間支出推移"; +"OpenRouter API key spend trend" = "OpenRouter APIキーの支出推移"; +"z.ai hourly token trend" = "z.ai の時間別トークン推移"; +"MiniMax 30 day token usage trend" = "MiniMax の30日間トークン使用量推移"; +"Today cash" = "本日の現金"; +"DeepSeek 30 day token usage trend" = "DeepSeek の30日間トークン使用量推移"; +"cache-hit input" = "キャッシュヒット入力"; +"cache-miss input" = "キャッシュミス入力"; +"output" = "出力"; +"Requests" = "リクエスト"; +"Reported by OpenAI Admin API organization usage." = "OpenAI Admin API の組織使用量から報告されています。"; +"Reported by Mistral billing usage." = "Mistral の請求使用量から報告されています。"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "選択したホスト上で GitHub OAuth Device Flow を使ってアカウントを追加します。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "サインイン済みの各 Google アカウントを保存し、Antigravity をすばやく切り替えられるようにします。利用可能な場合は Antigravity.app の OAuth を使用し、上書き設定として ANTIGRAVITY_OAUTH_CLIENT_ID と ANTIGRAVITY_OAUTH_CLIENT_SECRET を使用できます。"; +"Manual cleanup: past sessions" = "手動クリーンアップ: 過去のセッション"; +"Clearing removes past resume, continue, and rewind history." = "消去すると、過去の再開・継続・巻き戻しの履歴が削除されます。"; +"Manual cleanup: file checkpoints" = "手動クリーンアップ: ファイルチェックポイント"; +"Clearing removes checkpoint restore data for previous edits." = "消去すると、過去の編集のチェックポイント復元データが削除されます。"; +"Manual cleanup: saved plans" = "手動クリーンアップ: 保存済みプラン"; +"Clearing removes old plan-mode files." = "消去すると、古いプランモードのファイルが削除されます。"; +"Manual cleanup: debug logs" = "手動クリーンアップ: デバッグログ"; +"Clearing removes past debug logs." = "消去すると、過去のデバッグログが削除されます。"; +"Manual cleanup: attachment cache" = "手動クリーンアップ: 添付ファイルキャッシュ"; +"Clearing removes cached large pastes or attached images." = "消去すると、キャッシュされた大きなペースト内容や添付画像が削除されます。"; +"Manual cleanup: session metadata" = "手動クリーンアップ: セッションメタデータ"; +"Clearing removes per-session environment metadata." = "消去すると、セッションごとの環境メタデータが削除されます。"; +"Manual cleanup: shell snapshots" = "手動クリーンアップ: シェルスナップショット"; +"Clearing removes leftover runtime shell snapshot files." = "消去すると、残存しているランタイムシェルのスナップショットファイルが削除されます。"; +"Manual cleanup: legacy todos" = "手動クリーンアップ: レガシー ToDo"; +"Clearing removes legacy per-session task lists." = "消去すると、セッションごとのレガシータスクリストが削除されます。"; +"Manual cleanup: sessions" = "手動クリーンアップ: セッション"; +"Clearing removes past Codex session history." = "消去すると、過去の Codex セッション履歴が削除されます。"; +"Manual cleanup: archived sessions" = "手動クリーンアップ: アーカイブ済みセッション"; +"Clearing removes archived Codex session history." = "消去すると、アーカイブされた Codex セッション履歴が削除されます。"; +"Manual cleanup: cache" = "手動クリーンアップ: キャッシュ"; +"Clearing removes provider-owned cached data." = "消去すると、プロバイダが保持するキャッシュデータが削除されます。"; +"Manual cleanup: logs" = "手動クリーンアップ: ログ"; +"Clearing removes local diagnostic logs." = "消去すると、ローカルの診断ログが削除されます。"; +"Manual cleanup: file history" = "手動クリーンアップ: ファイル履歴"; +"Clearing removes local edit checkpoint history." = "消去すると、ローカルの編集チェックポイント履歴が削除されます。"; +"Manual cleanup: temporary data" = "手動クリーンアップ: 一時データ"; +"Clearing removes local temporary provider data." = "消去すると、ローカルのプロバイダ一時データが削除されます。"; +"Total: %@" = "合計: %@"; +"%d more items" = "他 %d 件の項目"; +"Cleanup ideas" = "クリーンアップの候補"; +"%d unreadable item(s) skipped" = "読み取れない項目 %d 件をスキップしました"; + +"API key limit" = "APIキー上限"; +"Auth" = "認証"; +"Auto" = "自動"; +"Disabled — no recent data" = "無効 — 最近のデータなし"; +"Limits not available" = "上限情報なし"; +"No usage yet" = "まだ使用量がありません"; +"Not fetched yet" = "未取得"; +"Refreshing" = "更新中"; +"Session" = "セッション"; +"Source" = "ソース"; +"State" = "状態"; +"Unavailable" = "利用不可"; +"Weekly" = "週間"; +"not detected" = "未検出"; +"Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"minimax_usage_amount_format" = "使用量: %@ / %@"; +"minimax_used_percent_format" = "使用済み %@"; +"minimax_service_text_generation" = "テキスト生成"; +"minimax_service_text_to_speech" = "音声合成"; +"minimax_service_music_generation" = "音楽生成"; +"minimax_service_image_generation" = "画像生成"; +"minimax_service_lyrics_generation" = "歌詞生成"; +"minimax_service_coding_plan_vlm" = "コーディングプラン VLM"; +"minimax_service_coding_plan_search" = "コーディングプラン検索"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ は許可を待っています"; +"%@ requests" = "%@ リクエスト"; +"%@: %@ credits" = "%@: %@ クレジット"; +"30d requests" = "30日間のリクエスト"; +"4 days" = "4日間"; +"5 days" = "5日間"; +"7 days" = "7日間"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "APIキーで Ollama Cloud へのアクセスを確認できますが、クォータ上限の取得には引き続き Cookie が必要です。"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS アクセスキー ID。AWS_ACCESS_KEY_ID でも設定できます。"; +"AWS region. Can also be set with AWS_REGION." = "AWS リージョン。AWS_REGION でも設定できます。"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS シークレットアクセスキー。AWS_SECRET_ACCESS_KEY でも設定できます。"; +"Access key ID" = "アクセスキー ID"; +"Add Account" = "アカウントを追加"; +"Adding Account…" = "アカウントを追加中…"; +"Antigravity login failed" = "Antigravity のログインに失敗しました"; +"Antigravity login timed out" = "Antigravity のログインがタイムアウトしました"; +"Auth source" = "認証ソース"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動では Xiaomi MiMo のブラウザ Cookie を読み込みます。"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "自動では Chromium ブラウザの localStorage から Windsurf セッションデータを読み込みます。"; +"Automatic imports browser cookies from Bailian." = "自動では Bailian のブラウザ Cookie を読み込みます。"; +"Automatically imports browser cookies." = "ブラウザの Cookie を自動的に読み込みます。"; +"Automatically imports browser session cookies." = "ブラウザのセッション Cookie を自動的に読み込みます。"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI のデプロイメント名。AZURE_OPENAI_DEPLOYMENT_NAME もサポートされています。"; +"Azure OpenAI key" = "Azure OpenAI キー"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI リソースのエンドポイント。AZURE_OPENAI_ENDPOINT もサポートされています。"; +"Base URL" = "ベース URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy インスタンスのベース URL。"; +"Browser cookies" = "ブラウザ Cookie"; +"Cap end" = "上限終了"; +"Cap start" = "上限開始"; +"Capacity End" = "キャパシティ終了"; +"Capacity Start" = "キャパシティ開始"; +"Changelog" = "変更履歴"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "国際アカウントまたは中国本土アカウント用の Moonshot/Kimi API ホストを選択します。"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "QuotaKit は、APIキーのみの構成でサインインしているシステムアカウントを置き換えることはできません。"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit はそのアカウントの保存済み認証情報を見つけられませんでした。再認証してからやり直してください。"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "QuotaKit は管理アカウントのストレージを読み取れませんでした。別のアカウントを追加する前にストアを復旧してください。"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "QuotaKit はそのアカウントの保存済み認証情報を読み取れませんでした。再認証してからやり直してください。"; +"CodexBar could not read the current system account on this Mac." = "QuotaKit はこの Mac の現在のシステムアカウントを読み取れませんでした。"; +"CodexBar could not replace the live Codex auth on this Mac." = "QuotaKit はこの Mac の現在使用中の Codex 認証情報を置き換えられませんでした。"; +"CodexBar could not safely preserve the current system account before switching." = "QuotaKit は切り替え前に現在のシステムアカウントを安全に保全できませんでした。"; +"CodexBar could not save the current system account before switching." = "QuotaKit は切り替え前に現在のシステムアカウントを保存できませんでした。"; +"CodexBar could not update managed account storage." = "QuotaKit は管理アカウントのストレージを更新できませんでした。"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "QuotaKit は、現在のシステムアカウントをすでに使用している別の管理アカウントを検出しました。切り替える前に重複アカウントを解消してください。"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "QuotaKit はブラウザの Cookie を復号してアカウントを認証するために、macOS キーチェーンに「%@」へのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "QuotaKit は Claude の使用量を取得するために、macOS キーチェーンに Claude Code の OAuth トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Amp の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Augment の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "QuotaKit は Claude ウェブの使用量を取得するために、macOS キーチェーンに Claude の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Cursor の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Factory の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに GitHub Copilot のトークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Kimi K2 の APIキーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Kimi の認証トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに MiniMax の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに MiniMax の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "QuotaKit は Codex ダッシュボードの追加情報を取得するために、macOS キーチェーンに OpenAI の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに OpenCode の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに Synthetic の APIキーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "QuotaKit は使用量を取得するために、macOS キーチェーンに z.ai の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"Could not open Cursor login in your browser." = "ブラウザで Cursor のログイン画面を開けませんでした。"; +"Could not open browser for Antigravity" = "Antigravity 用のブラウザを開けませんでした"; +"Credits used" = "使用済みクレジット"; +"Day" = "日"; +"Deployment" = "デプロイメント"; +"Drag to reorder" = "ドラッグして並べ替え"; +"Endpoint" = "エンドポイント"; +"Enterprise host" = "Enterprise ホスト"; +"Extra usage balance: %@" = "追加使用量の残高: %@"; +"Keychain Access Required" = "キーチェーンへのアクセスが必要です"; +"Kiro menu bar value" = "Kiro メニューバー表示値"; +"Label" = "ラベル"; +"No organizations loaded. Click Refresh after setting your API key." = "組織が読み込まれていません。APIキーを設定してから「更新」をクリックしてください。"; +"No output captured." = "出力は取得されませんでした。"; +"No system account" = "システムアカウントなし"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment を開く(ログアウトして再ログイン)"; +"Open Codebuff Dashboard" = "Codebuff ダッシュボードを開く"; +"Open Command Code Settings" = "Command Code 設定を開く"; +"Open Crof dashboard" = "Crof ダッシュボードを開く"; +"Open Manus" = "Manus を開く"; +"Open MiMo Balance" = "MiMo 残高を開く"; +"Open Moonshot Console" = "Moonshot コンソールを開く"; +"Open Ollama API Keys" = "Ollama APIキーを開く"; +"Open StepFun Platform" = "StepFun プラットフォームを開く"; +"Open T3 Chat Settings" = "T3 Chat 設定を開く"; +"Open Volcengine Ark Console" = "Volcengine Ark コンソールを開く"; +"Open legacy provider docs" = "レガシープロバイダのドキュメントを開く"; +"Open projects" = "プロジェクトを開く"; +"Open this URL manually to continue login:\n\n%@" = "ログインを続けるには、この URL を手動で開いてください:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "複数の Anthropic 組織にリンクされたアカウント用のオプションの組織 ID。"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "オプション。設定済みの Admin APIキーに適用されます。選択したトークンアカウントには OPENAI_PROJECT_ID は引き継がれません。"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "オプション。GitHub Enterprise のホストを入力してください(例: octocorp.ghe.com)。github.com の場合は空欄のままにしてください。"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "オプション。空欄のままにすると、APIキーから参照できるプロジェクトを検出して集計します。"; +"Org ID (optional)" = "組織 ID(オプション)"; +"Organizations" = "組織"; +"Password" = "パスワード"; +"%@ authentication is disabled." = "%@ の認証は無効になっています。"; +"%@ cookies are disabled." = "%@ の Cookie は無効になっています。"; +"%@ web API access is disabled." = "%@ のウェブ API アクセスは無効になっています。"; +"Disable %@ dashboard cookie usage." = "%@ ダッシュボードの Cookie 使用を無効にします。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "詳細設定でキーチェーンへのアクセスが無効になっているため、ブラウザ Cookie の読み込みは利用できません。"; +"Manually paste an %@ from a browser session." = "ブラウザセッションから %@ を手動で貼り付けてください。"; +"Paste a Cookie header captured from %@." = "%@ から取得した Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header from %@." = "%@ の Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header or cURL capture from %@." = "%@ の Cookie ヘッダーまたは cURL キャプチャを貼り付けてください。"; +"Paste a Cookie header or full cURL capture from %@." = "%@ の Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste a Cookie or Authorization header from %@." = "%@ の Cookie または Authorization ヘッダーを貼り付けてください。"; +"Paste a full cookie header or the %@ value." = "完全な Cookie ヘッダーまたは %@ の値を貼り付けてください。"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "T3 Chat 設定から取得した Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。ory_session_* Cookie が含まれている必要があります。"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "platform.stepfun.com にログイン中のブラウザセッションから Oasis-Token を貼り付けてください。"; +"Paste the %@ JSON bundle from %@." = "%@ の JSON バンドルを %@ から貼り付けてください。"; +"Paste the %@ value or a full Cookie header." = "%@ の値または完全な Cookie ヘッダーを貼り付けてください。"; +"Personal account" = "個人アカウント"; +"Project ID" = "プロジェクト ID"; +"Re-auth" = "再認証"; +"Re-login at claude.ai" = "claude.ai で再ログイン"; +"Re-authenticating…" = "再認証中…"; +"Refresh Session" = "セッションを更新"; +"Refresh organizations" = "組織を更新"; +"Region" = "リージョン"; +"Reload" = "再読み込み"; +"Reorder" = "並べ替え"; +"Secret access key" = "シークレットアクセスキー"; +"Series" = "シリーズ"; +"Service" = "サービス"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "メニューバーアイコンの横に Kiro のクレジット、パーセント、またはその両方を表示/非表示にします。"; +"Show usage for organizations you belong to. Personal account is always shown." = "所属している組織の使用量を表示します。個人アカウントは常に表示されます。"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "ブラウザで cursor.com にサインインしてから、QuotaKit で Cursor を更新してください。"; +"Simulated error text" = "シミュレートされたエラーテキスト"; +"StepFun platform account (phone number or email)." = "StepFun プラットフォームのアカウント(電話番号またはメールアドレス)。"; +"Stored in ~/.codexbar/config.json." = "~/.quotakit/config.json に保存されます。"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "~/.quotakit/config.json に保存されます。AZURE_OPENAI_API_KEY もサポートされています。"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "~/.quotakit/config.json に保存されます。公式の Kimi API には Moonshot / Kimi API を使用してください。"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "~/.quotakit/config.json に保存されます。APIキーは Volcengine Ark コンソールから取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "~/.quotakit/config.json に保存されます。キーは Ollama の設定から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "~/.quotakit/config.json に保存されます。キーは console.deepgram.com から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "~/.quotakit/config.json に保存されます。キーは elevenlabs.io/app/settings/api-keys から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "~/.quotakit/config.json に保存されます。キーは openrouter.ai/settings/keys から取得し、そこでキーの支出上限を設定すると APIキーのクォータ追跡が有効になります。"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "~/.quotakit/config.json に保存されます。Warp で「Settings」>「Platform」>「API Keys」を開いて作成してください。"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "~/.quotakit/config.json に保存されます。メトリクスには Groq Enterprise の Prometheus アクセスが必要です。"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "~/.quotakit/config.json に保存されます。OPENAI_ADMIN_KEY が推奨されますが、OPENAI_API_KEY も引き続き使用できます。"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "~/.quotakit/config.json に保存されます。Anthropic の Admin APIキーが必要です。"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "~/.quotakit/config.json に保存されます。/v1/quota-stats に使用されます。"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "~/.quotakit/config.json に保存されます。CODEBUFF_API_KEY を指定するか、QuotaKit に ~/.config/manicode/credentials.json(`codebuff login` で作成)を読み込ませることもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "~/.quotakit/config.json に保存されます。CROF_API_KEY を指定することもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "~/.quotakit/config.json に保存されます。KILO_API_KEY または ~/.local/share/kilo/auth.json(kilo.access)を指定することもできます。"; +"T3 Chat cookie" = "T3 Chat Cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "そのアカウントは QuotaKit で利用できなくなっています。アカウントリストを更新してからやり直してください。"; +"The browser login did not complete in time. Try Antigravity login again." = "ブラウザでのログインが時間内に完了しませんでした。Antigravity のログインをもう一度お試しください。"; +"Timed out waiting for Cursor login. %@" = "Cursor のログイン待機がタイムアウトしました。%@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Cursor のログイン待機がタイムアウトしました。%@ 最後のエラー: %@"; +"Today requests" = "本日のリクエスト"; +"Total (30d): %@ credits" = "合計(30日間): %@ クレジット"; +"Username" = "ユーザ名"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "ユーザ名とパスワードでログインし、Oasis-Token を自動的に取得します。"; +"Uses username + password to login and obtain an %@ automatically." = "ユーザ名とパスワードでログインし、%@ を自動的に取得します。"; +"Utilization End" = "使用率終了"; +"Utilization Start" = "使用率開始"; +"Verbosity" = "詳細度"; +"Windsurf session JSON bundle" = "Windsurf セッション JSON バンドル"; +"Workspace ID" = "ワークスペース ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "StepFun プラットフォームのパスワード。ログインしてセッショントークンを取得するために使用されます。"; +"claude /login exited with status %d." = "claude /login がステータス %d で終了しました。"; +"codex login exited with status %d." = "codex login がステータス %d で終了しました。"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nまたは Abacus AI ダッシュボードからの cURL キャプチャを貼り付けてください"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nまたは __Secure-next-auth.session-token の値を貼り付けてください"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nまたは kimi-auth トークンの値を貼り付けてください"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nまたは session_id の値のみを貼り付けてください"; +"Clear" = "消去"; +"No matching providers" = "一致するプロバイダがありません"; +"Search providers" = "プロバイダを検索"; + +"language_vietnamese" = "ベトナム語"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 79affff89..0032ac526 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -802,6 +802,8 @@ "language_ukrainian" = "Oekraïens"; +"language_japanese" = "Japans"; + "language_swedish" = "Zweeds"; "language_vietnamese" = "Vietnamees"; @@ -1786,7 +1788,7 @@ "Auth source" = "Authenticatiebron"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importeert automatisch Chrome-browsercookies van Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importeert automatisch browsercookies van Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatische import van windsurfsessiegegevens uit de Chromium-browser localStorage."; @@ -1986,6 +1988,8 @@ "Re-auth" = "Opnieuw verifiëren"; +"Re-login at claude.ai" = "Opnieuw aanmelden bij claude.ai"; + "Re-authenticating…" = "Opnieuw authenticeren…"; "Refresh Session" = "Sessie vernieuwen"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index fa2a8b974..efd4b2f77 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Ucraniano"; +"language_japanese" = "Japonês"; + "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o QuotaKit automaticamente ao iniciar o Mac."; @@ -1784,7 +1786,7 @@ "Auth source" = "Fonte de autenticação"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do Chrome do Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do navegador do Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente dados de sessão do Windsurf do localStorage do Chromium."; @@ -1984,6 +1986,8 @@ "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Entrar novamente no claude.ai"; + "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Atualizar sessão"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ae6e96ed1..3b1f679d0 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -806,6 +806,8 @@ "language_ukrainian" = "Ukrainska"; +"language_japanese" = "Japanska"; + "start_at_login_title" = "Starta vid inloggning"; "start_at_login_subtitle" = "Öppnar QuotaKit automatiskt när du startar din Mac."; @@ -1978,6 +1980,8 @@ "Re-auth" = "Autentisera igen"; +"Re-login at claude.ai" = "Logga in igen på claude.ai"; + "cache-miss input" = "cachemiss-indata"; "Day" = "Dag"; @@ -2060,7 +2064,7 @@ "Auth source" = "Autentiseringskälla"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importerar Chrome-cookies från Xiaomi MiMo automatiskt."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importerar webbläsarcookies från Xiaomi MiMo automatiskt."; "No usage configured." = "Ingen användning konfigurerad."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 428341b28..d8876892e 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Українська"; +"language_japanese" = "Японська"; + "language_vietnamese" = "В'єтнамська"; "start_at_login_title" = "Почніть із входу"; @@ -1786,7 +1788,7 @@ "Auth source" = "Джерело авторизації"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера Chrome із Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера з Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматично імпортує дані сесії Windsurf із локального сховища браузера Chromium."; @@ -1986,6 +1988,8 @@ "Re-auth" = "Повторна авторизація"; +"Re-login at claude.ai" = "Повторно увійти на claude.ai"; + "Re-authenticating…" = "Повторна автентифікація…"; "Refresh Session" = "Оновити сеанс"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 6e8cc0094..381571e9e 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -804,6 +804,8 @@ "language_ukrainian" = "Tiếng Ukraina"; +"language_japanese" = "Tiếng Nhật"; + "start_at_login_title" = "Bắt đầu khi đăng nhập"; "start_at_login_subtitle" = "Tự động mở QuotaKit khi bạn khởi động máy Mac."; @@ -1784,7 +1786,7 @@ "Auth source" = "Nguồn xác thực"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt Chrome từ Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt từ Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Tự động nhập dữ liệu phiên Windsurf từ localStorage của trình duyệt Chrome."; @@ -1984,6 +1986,8 @@ "Re-auth" = "Xác thực lại"; +"Re-login at claude.ai" = "Đăng nhập lại vào claude.ai"; + "Re-authenticating…" = "Xác thực lại…"; "Refresh Session" = "Làm mới phiên"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 5974577c1..afce688fb 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -820,6 +820,8 @@ "language_ukrainian" = "乌克兰语"; +"language_japanese" = "日语"; + "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 QuotaKit。"; @@ -1808,7 +1810,7 @@ "Auth source" = "认证来源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的 Chrome 浏览器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的浏览器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自动从 Chromium 浏览器 localStorage 导入 Windsurf 会话数据。"; @@ -2008,6 +2010,8 @@ "Re-auth" = "重新认证"; +"Re-login at claude.ai" = "重新登录 claude.ai"; + "Re-authenticating…" = "正在重新认证…"; "Refresh Session" = "刷新会话"; @@ -2120,3 +2124,5 @@ "language_vietnamese" = "越南语"; + +"Request quota: %@ / %@" = "请求额度:%@ / %@"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 4b49493bb..595a60115 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -820,6 +820,8 @@ "language_ukrainian" = "烏克蘭語"; +"language_japanese" = "日語"; + "start_at_login_title" = "登入時啟動"; "start_at_login_subtitle" = "登入 Mac 時自動開啟 QuotaKit。"; @@ -1552,7 +1554,7 @@ "Auth source" = "認證來源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的 Chrome 瀏覽器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的瀏覽器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自動從 Chromium 瀏覽器 localStorage 匯入 Windsurf 工作階段資料。"; @@ -1752,6 +1754,8 @@ "Re-auth" = "重新認證"; +"Re-login at claude.ai" = "重新登入 claude.ai"; + "Re-authenticating…" = "正在重新認證…"; "Refresh Session" = "重新整理工作階段"; @@ -1864,3 +1868,5 @@ "language_vietnamese" = "越南語"; + +"Request quota: %@ / %@" = "請求額度:%@ / %@"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 9dca338d8..015ddad49 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -683,6 +683,14 @@ extension SettingsStore { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } } + + var terminalApp: TerminalApp { + get { TerminalApp(rawValue: self.defaultsState.terminalAppRaw ?? "") ?? .terminal } + set { + self.defaultsState.terminalAppRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "terminalApp") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6d4c87ceb..350a7468e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -114,6 +114,19 @@ struct CachedCodexAccountReconciliationSnapshot { let snapshot: CodexAccountReconciliationSnapshot } +struct CachedCodexAccountMenuProjection: Equatable { + let activeSource: CodexActiveSource + let loadedAt: Date + let projection: CodexVisibleAccountProjection +} + +enum CodexAccountMenuProjectionRevalidationResult: Equatable { + case skipped + case discarded + case unchanged + case updated +} + @MainActor @Observable final class SettingsStore { @@ -140,6 +153,12 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection? + @ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0 + #if DEBUG + @ObservationIgnored var _test_codexAccountSnapshotLoader: + (@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)? + #endif @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState @@ -403,7 +422,6 @@ extension SettingsStore { let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false let appLanguageRaw = userDefaults.string(forKey: "appLanguage") - return SettingsDefaultsState( refreshFrequency: refreshFrequency, launchAtLogin: launchAtLogin, @@ -455,7 +473,8 @@ extension SettingsStore { mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, providerDetectionCompleted: providerDetectionCompleted, - appLanguageRaw: appLanguageRaw) + appLanguageRaw: appLanguageRaw, + terminalAppRaw: userDefaults.string(forKey: "terminalApp")) } private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index c1cca3f43..f0868547b 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -52,4 +52,5 @@ struct SettingsDefaultsState { var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool var appLanguageRaw: String? + var terminalAppRaw: String? } diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index ea0bb9d2d..2c268511f 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -2,6 +2,30 @@ import AppKit import CodexBarCore extension StatusItemController { + private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests + + #if DEBUG + private static var codexAccountMenuProjectionRevalidationEnabledForTesting = + defaultCodexAccountMenuProjectionRevalidationEnabled + + static func setCodexAccountMenuProjectionRevalidationEnabledForTesting(_ enabled: Bool) { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = enabled + } + + static func resetCodexAccountMenuProjectionRevalidationEnabledForTesting() { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = + self.defaultCodexAccountMenuProjectionRevalidationEnabled + } + #endif + + private static var codexAccountMenuProjectionRevalidationEnabled: Bool { + #if DEBUG + self.codexAccountMenuProjectionRevalidationEnabledForTesting + #else + self.defaultCodexAccountMenuProjectionRevalidationEnabled + #endif + } + func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) @@ -35,7 +59,7 @@ extension StatusItemController { func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjection + guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil } guard projection.visibleAccounts.count > 1 else { return nil } let showAll = self.settings.multiAccountMenuLayout == .stacked let accounts = showAll @@ -52,6 +76,31 @@ extension StatusItemController { layout: showAll ? .stacked : .segmented) } + func scheduleCodexAccountMenuProjectionRevalidationIfNeeded(for providers: [UsageProvider]) { + guard Self.codexAccountMenuProjectionRevalidationEnabled else { return } + guard providers.contains(.codex) else { return } + guard self.settings.codexAccountMenuProjectionNeedsRevalidation else { return } + guard self.codexAccountMenuProjectionRevalidationTask == nil else { return } + + self.codexAccountMenuProjectionRevalidationTask = Task { @MainActor [weak self] in + guard let settings = self?.settings else { return } + let result = await settings.revalidateCodexAccountMenuProjection() + guard let self else { return } + guard !Task.isCancelled else { + self.codexAccountMenuProjectionRevalidationTask = nil + return + } + self.codexAccountMenuProjectionRevalidationTask = nil + + switch result { + case .updated: + self.invalidateMenus(refreshOpenMenus: false) + case .discarded, .skipped, .unchanged: + break + } + } + } + private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:] for snapshot in self.store.codexAccountSnapshots { diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index cd93de3d8..d0607c57c 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -42,7 +42,9 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } func refreshOpenMenusAfterExplicitStoreAction() { - self.invalidateMenus(refreshOpenMenus: true) + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) } @objc func refreshNow() { @@ -184,7 +186,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" - Self.openTerminal(command: command) + self.openTerminal(command: command) } @objc func openLoginToProvider(_ sender: NSMenuItem) { @@ -348,25 +350,48 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } - private static func openTerminal(command: String) { - let escaped = command - .replacingOccurrences(of: "\\\\", with: "\\\\\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = """ - tell application "Terminal" - activate - do script "\(escaped)" - end tell - """ - if let appleScript = NSAppleScript(source: script) { + func openTerminal(command: String) { + let terminal = self.settings.terminalApp + + if terminal == .iTerm, !terminal.isInstalled { + CodexBarLog.logger(LogCategories.terminal).warning( + "iTerm is not installed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + return + } + + if Self.executeAppleScript(terminal.appleScript(command: command)) { + return + } + guard terminal != .terminal else { return } + + CodexBarLog.logger(LogCategories.terminal).warning( + "\(terminal.label) AppleScript failed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + } + + private static func openTerminalInDefaultTerminal(command: String) { + self.executeAppleScript(TerminalApp.terminal.appleScript(command: command)) + } + + /// Executes an AppleScript and returns `true` on success, `false` on failure. + @discardableResult + private static func executeAppleScript(_ source: String) -> Bool { + if let appleScript = NSAppleScript(source: source) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", + "Failed to execute AppleScript", metadata: ["error": String(describing: error)]) + return false } + return true } + CodexBarLog.logger(LogCategories.terminal).error("Failed to compile AppleScript") + return false } private func resolvedShortcutProvider() -> UsageProvider { diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2ced7ae42..fdf79e114 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -257,8 +257,13 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { + func applyIcon( + phase: Double?, + bypassMergedMenuTrackingDeferral: Bool = false) -> Bool + { guard let button = self.statusItem.button else { return false } + if !bypassMergedMenuTrackingDeferral, + self.deferMergedIconRenderDuringMenuTrackingIfNeeded() { return true } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -498,6 +503,25 @@ extension StatusItemController { return false } + private func deferMergedIconRenderDuringMenuTrackingIfNeeded() -> Bool { + guard self.shouldMergeIcons, self.isMergedMenuOpen else { return false } + self.deferredMergedIconRenderAfterTracking = true + self.noteIconPerfRender(skipped: true) + return true + } + + func applyDeferredMergedIconRenderAfterTrackingIfNeeded() { + guard self.deferredMergedIconRenderAfterTracking else { return } + guard self.shouldMergeIcons else { + self.deferredMergedIconRenderAfterTracking = false + return + } + guard !self.isMergedMenuOpen else { return } + self.deferredMergedIconRenderAfterTracking = false + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase) + } + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { guard self.shouldMergeIcons else { self.lastAppliedMergedIconRenderSignature = signature @@ -698,6 +722,42 @@ extension StatusItemController { return false } + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + self?.clearExpiredQuotaWarningFlash(provider: provider) + } + } + } + + func clearExpiredQuotaWarningFlash(provider: UsageProvider, now: Date = Date()) { + guard let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= now + else { + return + } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + } + + private func applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() { + guard self.shouldMergeIcons, + self.isMergedMenuOpen + else { + return + } + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase, bypassMergedMenuTrackingDeferral: true) + } + static func quotaWarningFlashImage(base: NSImage) -> NSImage { let image = NSImage(size: base.size) image.lockFocus() diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index a0bf47939..c84f296f0 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -1,13 +1,73 @@ import AppKit +import SwiftUI + +private struct CostMenuCardRowView: View { + let title: String + let detailLines: [String] + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + ForEach(self.detailLines.indices, id: \.self) { index in + Text(self.detailLines[index]) + .font(.system(size: NSFont.smallSystemFontSize)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 6) + .frame(width: self.width, alignment: .leading) + } +} extension StatusItemController { static var costMenuTitle: String { L("Cost") } - func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { + func makeCostMenuCardItem( + model: UsageMenuCardView.Model, + submenu: NSMenu?, + width: CGFloat) -> NSMenuItem + { let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) let visibleDetailLines = Self.costMenuVisibleDetailLines(tokenUsage: model.tokenUsage) + guard Self.menuCardRenderingEnabled else { + return Self.makeNativeCostMenuCardItem( + visibleDetailLines: visibleDetailLines, + tooltipLines: tooltipLines, + submenu: submenu) + } + + let item = self.makeMenuCardItem( + CostMenuCardRowView( + title: Self.costMenuTitle, + detailLines: visibleDetailLines, + width: width), + id: "menuCardCost", + width: width, + heightCacheScope: model.provider.rawValue, + heightCacheFingerprint: "costMenuRow:\(visibleDetailLines.count)", + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + item.title = Self.costMenuTitle + item.toolTip = tooltipLines.joined(separator: "\n") + return item + } + + private static func makeNativeCostMenuCardItem( + visibleDetailLines: [String], + tooltipLines: [String], + submenu: NSMenu?) -> NSMenuItem + { let item = NSMenuItem(title: Self.costMenuTitle, action: nil, keyEquivalent: "") item.isEnabled = true item.representedObject = "menuCardCost" diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index b4c7bfad2..3e5fdf44b 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -1,8 +1,15 @@ import AppKit import CodexBarCore +import QuartzCore import SwiftUI extension StatusItemController { + private struct HostedSubviewIdentity { + let chartID: String + let provider: UsageProvider? + let providerRawValue: String? + } + func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ Self.usageBreakdownChartID, @@ -37,18 +44,24 @@ extension StatusItemController { return submenu } - func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) { + @discardableResult + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) -> Bool { guard let placeholder = menu.items.first, menu.items.count == 1, placeholder.view == nil, let chartID = placeholder.representedObject as? String else { - return + return false } let width = requestedWidth ?? self.renderedMenuWidth(for: menu.supermenu ?? menu) + let identity = HostedSubviewIdentity( + chartID: chartID, + provider: placeholder.toolTip.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: placeholder.toolTip) menu.removeAllItems() + let t0 = CACurrentMediaTime() let didHydrate: Bool = switch chartID { case Self.usageBreakdownChartID: self.appendUsageBreakdownChartItem(to: menu, width: width) @@ -89,9 +102,16 @@ extension StatusItemController { default: false } + self.logChartRenderDurationIfSlow("hydrateHostedSubview:\(chartID)", startedAt: t0) - guard !didHydrate else { return } - self.appendHostedSubviewUnavailableItem(to: menu, chartID: chartID, providerRawValue: placeholder.toolTip) + if !didHydrate { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: chartID, + providerRawValue: placeholder.toolTip) + } + self.recordHostedSubviewRenderSignature(for: menu, identity: identity, width: width) + return true } func refreshHostedSubviewMenu(_ menu: NSMenu) { @@ -100,8 +120,16 @@ extension StatusItemController { self.refreshHostedSubviewHeights(in: menu) return } + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + if self.hostedSubviewRenderSignatures.object(forKey: menu) as String? == signature { + if identity.chartID == Self.zaiHourlyUsageChartID { + self.refreshHostedSubviewHeights(in: menu) + } + return + } menu.removeAllItems() + let t0 = CACurrentMediaTime() let didHydrate: Bool = switch identity.chartID { case Self.usageBreakdownChartID: self.appendUsageBreakdownChartItem(to: menu, width: width) @@ -134,23 +162,23 @@ extension StatusItemController { default: false } + self.logChartRenderDurationIfSlow("refreshHostedSubview:\(identity.chartID)", startedAt: t0) - if didHydrate { - self.refreshHostedSubviewHeights(in: menu) - } else { + if !didHydrate { self.appendHostedSubviewUnavailableItem( to: menu, chartID: identity.chartID, providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) } + self.hostedSubviewRenderSignatures.setObject(signature as NSString, forKey: menu) } private func hostedSubviewIdentity(for menu: NSMenu) - -> (chartID: String, provider: UsageProvider?, providerRawValue: String?)? { + -> HostedSubviewIdentity? { for item in menu.items { guard let chartID = item.representedObject as? String else { continue } let providerRawValue = item.toolTip - return ( + return HostedSubviewIdentity( chartID: chartID, provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), providerRawValue: providerRawValue) @@ -158,6 +186,117 @@ extension StatusItemController { return nil } + private func recordHostedSubviewRenderSignature( + for menu: NSMenu, + identity: HostedSubviewIdentity, + width: CGFloat) + { + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + self.hostedSubviewRenderSignatures.setObject(signature as NSString, forKey: menu) + } + + private func hostedSubviewRenderSignature( + identity: HostedSubviewIdentity, + width: CGFloat) -> String + { + let contentSignature: String = switch identity.chartID { + case Self.usageBreakdownChartID: + Self.dashboardBreakdownReadinessSignature( + OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? [])) + case Self.creditsHistoryChartID: + Self.dashboardBreakdownReadinessSignature(self.store.openAIDashboard?.dailyBreakdown ?? []) + case Self.costHistoryChartID: + identity.provider.map(self.costHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.usageHistoryChartID: + identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.storageBreakdownID: + identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider" + case Self.zaiHourlyUsageChartID: + identity.provider.map(self.zaiHourlyUsageRenderSignature(for:)) ?? "missing-provider" + default: + "unknown" + } + return [ + identity.chartID, + identity.providerRawValue ?? "", + String(Double(width).bitPattern, radix: 16), + contentSignature, + ].joined(separator: "|") + } + + private func costHistoryRenderSignature(for provider: UsageProvider) -> String { + guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return "none" } + return [ + snapshot.currencyCode, + "\(snapshot.historyDays)", + snapshot.historyLabel ?? "", + snapshot.last30DaysCostUSD.map { String($0.bitPattern, radix: 16) } ?? "nil", + String(reflecting: snapshot.daily), + ].joined(separator: "|") + } + + private func usageHistoryRenderSignature(for provider: UsageProvider) -> String { + let snapshot = self.store.snapshot(for: provider) + let selection = self.store.planUtilizationHistorySelection(for: provider) + return [ + "\(self.store.planUtilizationHistoryRevision)", + "\(Int(Date().timeIntervalSince1970 / 60))", + selection.accountKey ?? "unscoped", + snapshot?.primary == nil ? "0" : "1", + snapshot?.secondary == nil ? "0" : "1", + snapshot?.tertiary == nil ? "0" : "1", + ].joined(separator: "|") + } + + private func storageBreakdownRenderSignature(for provider: UsageProvider) -> String { + guard let footprint = self.store.storageFootprint(for: provider) else { return "none" } + let components = footprint.components + .map { "\($0.path)=\($0.totalBytes)" } + .joined(separator: ";") + return [ + "\(footprint.totalBytes)", + footprint.paths.joined(separator: ";"), + footprint.missingPaths.joined(separator: ";"), + footprint.unreadablePaths.joined(separator: ";"), + components, + String(Double(self.storageBreakdownMenuMaxHeight()).bitPattern, radix: 16), + ].joined(separator: "|") + } + + private func zaiHourlyUsageRenderSignature(for provider: UsageProvider) -> String { + guard let modelUsage = self.store.snapshot(for: provider)?.zaiUsage?.modelUsage else { return "none" } + return Self.zaiHourlyUsageRenderSignature(modelUsage: modelUsage, now: Date()) + } + + static func zaiHourlyUsageRenderSignature(modelUsage: ZaiModelUsageData, now: Date) -> String { + let models = modelUsage.modelDataList + .map { model in + let usage = model.tokensUsage + .map { $0.map(String.init) ?? "nil" } + .joined(separator: ",") + return "\(model.modelName ?? "")=\(usage)" + } + .joined(separator: ";") + let ranges: [ZaiHourlyRange] = [.today(referenceDate: now), .last24h] + let visibleBars = ranges + .map { range in + ZaiHourlyBars.from(modelData: modelUsage, range: range, now: now) + .map { bar in + let segments = bar.segments + .map { "\($0.model)=\($0.tokens)" } + .joined(separator: ",") + return "\(bar.label):\(segments)" + } + .joined(separator: ";") + } + return [ + modelUsage.xTime.joined(separator: ","), + models, + visibleBars.joined(separator: "|"), + ].joined(separator: "|") + } + private func appendHostedSubviewUnavailableItem( to menu: NSMenu, chartID: String, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index db4b49abc..dbc61ab62 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -49,15 +49,6 @@ extension StatusItemController { } } - private func menuCardWidth( - for providers: [UsageProvider], - sections: [MenuDescriptor.Section]) -> CGFloat - { - _ = providers - let baselineWidth = Self.menuCardBaseWidth - return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -84,8 +75,9 @@ extension StatusItemController { let menuTrackingWasIdle = self.openMenus.isEmpty if self.isHostedSubviewMenu(menu) { - self.hydrateHostedSubviewMenuIfNeeded(menu) - self.refreshHostedSubviewHeights(in: menu) + if !self.hydrateHostedSubviewMenuIfNeeded(menu) { + self.refreshHostedSubviewMenu(menu) + } if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "submenu open") } @@ -129,6 +121,8 @@ extension StatusItemController { let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) self.refreshMenuForOpenIfNeeded(menu, provider: provider) + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.renderedProviders(for: menu)) if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. @@ -158,6 +152,7 @@ extension StatusItemController { func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + let wasMergedMenu = menu === self.mergedMenu if key == self.providerSwitcherShortcutMenuID { self.removeProviderSwitcherShortcutMonitor() @@ -184,6 +179,9 @@ extension StatusItemController { } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() + if wasMergedMenu { + self.applyDeferredMergedIconRenderAfterTrackingIfNeeded() + } } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { @@ -212,6 +210,7 @@ extension StatusItemController { menu: menu, provider: provider) } + defer { self.refreshMenuCardHeights(in: menu) } let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -239,16 +238,13 @@ extension StatusItemController { let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllAccounts: showAllAccounts) - let descriptor = MenuDescriptor.build( + let descriptor = self.makeMenuDescriptor( provider: selectedProvider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, includeContextualActions: !isOverviewSelected) - let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: selectedProvider, + descriptor: descriptor) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } @@ -386,88 +382,15 @@ extension StatusItemController { return reusableRows } - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private struct MenuUpdateContext { - let provider: UsageProvider? - let currentProvider: UsageProvider - let switcherSelection: ProviderSwitcherSelection - let menuWidth: CGFloat - let codexAccountDisplay: CodexAccountMenuDisplay? - let tokenAccountDisplay: TokenAccountMenuDisplay? - let openAIContext: OpenAIWebContext - let descriptor: MenuDescriptor - } - - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private func updateMenuContentPreservingSwitcher( - _ menu: NSMenu, - context: MenuUpdateContext) - { - self.performMenuMutationWithoutAnimation { - let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) - if let switcherView = menu.items.first?.view as? ProviderSwitcherView { - switcherView.updateSelection(context.switcherSelection) - switcherView.updateQuotaIndicators() - } - if let outgoingSelection = self.lastMergedMenuContentSelection, - outgoingSelection != context.switcherSelection - { - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: outgoingSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth) - } - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) - } - - let enabledProviders = self.store.enabledProvidersForDisplay() - self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) - if self.addCachedMergedSwitcherContent( - for: context.switcherSelection, - to: menu, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay) - { - return - } - self.addCodexAccountSwitcherIfNeeded( - to: menu, - display: context.codexAccountDisplay, - width: context.menuWidth) - self.lastCodexAccountMenuDisplay = context.codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded( - to: menu, - display: context.tokenAccountDisplay, - width: context.menuWidth) - self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - - let menuContext = MenuCardContext( - currentProvider: context.currentProvider, - selectedProvider: context.provider, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay, - openAIContext: context.openAIContext) - self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: context.switcherSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth, - contentVersion: self.menuContentVersion) - } - } - private func rebuildMenuContent( _ menu: NSMenu, context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + let displacedSelection = self.lastMergedMenuContentSelection self.lastMergedMenuContentSelection = nil + self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection) + defer { self.clearMenuCardViewRecyclePool() } menu.removeAllItems() let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( @@ -566,16 +489,32 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { + func addTokenAccountSwitcherIfNeeded( + to menu: NSMenu, + display: TokenAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeTokenAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { + func addCodexAccountSwitcherIfNeeded( + to menu: NSMenu, + display: CodexAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeCodexAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -584,8 +523,12 @@ extension StatusItemController { private func addOverviewRows( to menu: NSMenu, enabledProviders: [UsageProvider], - menuWidth: CGFloat) -> Bool + menuWidth: CGFloat, + captureMenu: NSMenu? = nil) -> Bool { + // Rows may be built into a detached scratch menu for in-place reconciliation; + // interaction closures must always reference the live menu they end up serving. + let interactionMenu = captureMenu ?? menu let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders @@ -596,6 +539,9 @@ extension StatusItemController { } guard !rows.isEmpty else { return false } + let t0 = CACurrentMediaTime() + defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) @@ -612,9 +558,9 @@ extension StatusItemController { section: "overview", additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, - onClick: { [weak self, weak menu] in - guard let self, let menu else { return } - self.selectOverviewProvider(row.provider, menu: menu) + onClick: { [weak self, weak interactionMenu] in + guard let self, let interactionMenu else { return } + self.selectOverviewProvider(row.provider, menu: interactionMenu) }) if submenu == nil { // Keep plain rows wired for keyboard activation and accessibility action paths. @@ -764,17 +710,19 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addPrimaryMenuContent( + func addPrimaryMenuContent( to menu: NSMenu, context: MenuCardContext, - switcherSelection: ProviderSwitcherSelection) + switcherSelection: ProviderSwitcherSelection, + captureMenu: NSMenu? = nil) { if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, - menuWidth: context.menuWidth) + menuWidth: context.menuWidth, + captureMenu: captureMenu) { menu.addItem(.separator()) } else { @@ -805,7 +753,12 @@ extension StatusItemController { } } - func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { + func addActionableSections( + _ sections: [MenuDescriptor.Section], + to menu: NSMenu, + width: CGFloat, + captureMenu: NSMenu? = nil) + { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } @@ -839,7 +792,7 @@ extension StatusItemController { menu.addItem(self.makePersistentMenuActionItem( title: localizedTitle, action: action, - menu: menu, + menu: captureMenu ?? menu, width: width)) continue } @@ -1177,8 +1130,11 @@ extension StatusItemController { // provider fetch failed and needs a retry; periodic freshness is handled by the refresh timer. // AppKit menu tracking is modal, so starting provider refreshes while it is active can make the menu // feel frozen and can block keyboard focus from returning. - if self.menuNeedsDelayedRefreshRetry(for: menu) { - self.deferMenuInteractionRefreshIfNeeded() + let providersNeedingRetry = self.delayedRefreshRetryProviders(for: menu).filter { + self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil + } + if !providersNeedingRetry.isEmpty { + self.deferMenuInteractionRefreshIfNeeded(providers: providersNeedingRetry) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -1191,13 +1147,43 @@ extension StatusItemController { self.onDelayedMenuRefreshAttemptForTesting?() #endif guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard !self.store.isRefreshing else { return } - let retryProviders = self.delayedRefreshRetryProviders(for: menu) - let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } - let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } - let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 - guard willRetryRefresh else { return } - self.deferMenuInteractionRefreshIfNeeded() + let availableProviders = Set(self.store.enabledProvidersForBackgroundWork()) + let retryProviders = self.delayedRefreshRetryProviders(for: menu).filter { + availableProviders.contains($0) && + (self.store.refreshingProviders.contains($0) || + self.store.isStale(provider: $0) || + self.store.snapshot(for: $0) == nil) + } + guard !retryProviders.isEmpty else { + self.clearSatisfiedDeferredMenuInteractionRefreshes( + for: self.delayedRefreshRetryProviders(for: menu)) + if self.menuNeedsRefresh(menu) { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: self.menuProvider(for: menu), + resyncReadinessBaselineAfterRebuild: self.openMenus.count == 1) + } + return + } + self.deferMenuInteractionRefreshIfNeeded(providers: retryProviders) + await ProviderInteractionContext.$current.withValue(.background) { + for provider in retryProviders { + guard !Task.isCancelled else { return } + await self.store.refreshProvider(provider, coalesceIfRefreshing: true) + } + } + let stillNeedsRetry = retryProviders.contains { + self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil + } + if !stillNeedsRetry { + self.clearSatisfiedDeferredMenuInteractionRefreshes(for: retryProviders) + } + guard !Task.isCancelled else { return } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: false, + allowStaleContentDuringDataRefresh: true) } } @@ -1210,6 +1196,10 @@ extension StatusItemController { } private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] { + self.renderedProviders(for: menu) + } + + func renderedProviders(for menu: NSMenu) -> [UsageProvider] { let enabledProviders = self.store.enabledProvidersForDisplay() guard !enabledProviders.isEmpty else { return [] } let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -1336,7 +1326,10 @@ extension StatusItemController { } let costSubmenu = webItems.hasCostHistory ? self .makeCostHistorySubmenu(provider: provider, width: width) : nil - menu.addItem(self.makeCostMenuCardItem(model: model, submenu: costSubmenu)) + menu.addItem(self.makeCostMenuCardItem( + model: model, + submenu: costSubmenu, + width: width)) } } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 768b0fcf8..0f7a07505 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -3,12 +3,10 @@ import SwiftUI extension StatusItemController { func refreshMenuCardHeights(in menu: NSMenu) { - 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.renderedMenuWidth(for: menu) + let width = self.renderedMenuWidth(for: menu) + for item in menu.items { + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard abs(view.frame.width - width) > 0.5 else { continue } let id = item.representedObject as? String ?? "menuCard" let scope = self.menuProvider(for: menu)?.rawValue ?? id let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { @@ -20,8 +18,8 @@ extension StatusItemController { } } - func makeMenuCardItem( - _ view: some View, + func makeMenuCardItem( + _ view: CardContent, id: String, width: CGFloat, heightCacheScope: String? = nil, @@ -43,16 +41,33 @@ extension StatusItemController { return item } - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) + let hosting: MenuCardItemHostingView> + if let recycled = self.takeRecyclableMenuCardView( + for: id, + as: MenuCardItemHostingView>.self) { - view + let wrapped = MenuCardSectionContainerView( + highlightState: recycled.highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + recycled.prepareForReuse(rootView: wrapped, onClick: onClick) + hosting = recycled + } else { + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) let height = self.cachedMenuCardHeight( for: id, scope: heightCacheScope ?? id, diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 7c17a6559..eebc4b830 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -101,7 +101,7 @@ extension StatusItemController { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: fallbackAccount, - isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), + isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target), lastError: errorOverride ?? codexProjection?.userFacingErrors.usage ?? self.store.userFacingError(for: target), diff --git a/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift new file mode 100644 index 000000000..718e2df15 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift @@ -0,0 +1,69 @@ +import AppKit + +extension StatusItemController { + /// Collects the card hosting views of items the current populate pass is about to discard + /// so `makeMenuCardItem` can reuse them for cards with the same identifier (or, failing + /// that, the same content type) instead of building fresh hosting views. + /// + /// Safety: live menu items can alias one merged-switcher cache entry — the one for the + /// selection currently displayed, re-cached at the end of every populate. Consuming that + /// entry up front (`displacedSelection`) guarantees no cache entry can still reference a + /// harvested view; entries for other selections only hold items already detached from the + /// menu. Harvested views are detached from their outgoing items; whatever the pass does + /// not consume is released by `clearMenuCardViewRecyclePool`. + func harvestRecyclableMenuCardViews( + in menu: NSMenu, + fromIndex: Int, + displacedSelection: ProviderSwitcherSelection?, + preserveHighlightedItem: Bool = false) + { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + let menuKey = ObjectIdentifier(menu) + if let displacedSelection { + self.mergedSwitcherContentCaches[menuKey]?.removeValue(forKey: displacedSelection) + } + guard Self.menuCardRenderingEnabled else { return } + guard fromIndex >= 0, fromIndex < menu.items.count else { return } + for item in menu.items[fromIndex...] { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard self.menuCardViewRecyclePool[id] == nil else { continue } + // Unhighlight before detaching: the highlight tracker unwinds through the + // outgoing item's `view`, which is about to become nil, so a recycled view + // would otherwise re-attach visibly highlighted with no path to clear it. + if self.highlightedMenuItems[menuKey] === item { + if !preserveHighlightedItem { + self.highlightedMenuItems.removeValue(forKey: menuKey) + } + } + (view as? MenuCardHighlighting)?.setHighlighted(false) + item.view = nil + self.menuCardViewRecyclePool[id] = view + } + } + + /// Pops a pool entry adoptable as `ViewType`: the same card identifier when its view + /// matches, otherwise the first type-compatible leftover. The fallback is what makes + /// provider switches cheap — a different provider's card with a different identifier but + /// the same SwiftUI content type (for example two providers' usage cards) is repainted + /// in place instead of being rebuilt. + func takeRecyclableMenuCardView(for id: String, as type: ViewType.Type) -> ViewType? { + if let candidate = self.menuCardViewRecyclePool.removeValue(forKey: id) { + if let adopted = candidate as? ViewType { + return adopted + } + // A same-id view of an incompatible shape can never be adopted later in this + // pass; dropping it restores the build-fresh behavior. + return nil + } + guard let match = self.menuCardViewRecyclePool.first(where: { $0.value is ViewType }) else { + return nil + } + self.menuCardViewRecyclePool.removeValue(forKey: match.key) + return match.value as? ViewType + } + + func clearMenuCardViewRecyclePool() { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift index d5d0ff1c4..5cfcf7656 100644 --- a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -5,6 +5,7 @@ import QuartzCore extension StatusItemController { private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) private static let slowMenuOperationThreshold: TimeInterval = 0.15 + private static let slowChartRenderThreshold: TimeInterval = 0.050 #if DEBUG private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250) @@ -46,9 +47,28 @@ extension StatusItemController { ]) } - func deferMenuInteractionRefreshIfNeeded() { + func logChartRenderDurationIfSlow(_ label: String, startedAt: CFTimeInterval) { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowChartRenderThreshold else { return } + self.menuLogger.warning( + "slow chart render", + metadata: [ + "section": label, + "durationMs": String(format: "%.1f", elapsed * 1000), + ]) + } + + func deferMenuInteractionRefreshIfNeeded(providers: [UsageProvider]) { guard !self.store.isRefreshing else { return } - self.deferredMenuInteractionRefreshPending = true + self.deferredMenuInteractionRefreshProviders.formUnion(providers) + } + + func clearSatisfiedDeferredMenuInteractionRefreshes(for providers: [UsageProvider]) { + for provider in providers + where !self.store.isStale(provider: provider) && self.store.snapshot(for: provider) != nil + { + self.deferredMenuInteractionRefreshProviders.remove(provider) + } } func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) { @@ -98,7 +118,7 @@ extension StatusItemController { return } self.deferredMenuInteractionRefreshTask = nil - self.deferredMenuInteractionRefreshPending = false + self.deferredMenuInteractionRefreshProviders.removeAll() self.deferredOpenAIDashboardRefreshReason = nil #if DEBUG self.onDeferredMenuInteractionRefreshForTesting?() diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 527e35439..0b3de3675 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -82,8 +82,9 @@ final class MenuHostingView: NSHostingView { @MainActor final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { - private let highlightState: MenuCardHighlightState - private let onClick: (() -> Void)? + let highlightState: MenuCardHighlightState + private var onClick: (() -> Void)? + private var hasClickRecognizer = false override var allowsVibrancy: Bool { true @@ -100,12 +101,29 @@ final class MenuCardItemHostingView: NSHostingView, Menu self.onClick = onClick super.init(rootView: rootView) if onClick != nil { - let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) - recognizer.buttonMask = 0x1 - self.addGestureRecognizer(recognizer) + self.installClickRecognizer() } } + /// Reuses this hosting view for a rebuilt card with the same identity: the replaced + /// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the + /// hosting view and its graph. Callers must construct `rootView` around this view's own + /// `highlightState` so menu hover highlighting keeps driving the rendered content. + func prepareForReuse(rootView: Content, onClick: (() -> Void)?) { + self.rootView = rootView + self.onClick = onClick + if onClick != nil, !self.hasClickRecognizer { + self.installClickRecognizer() + } + } + + private func installClickRecognizer() { + let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) + recognizer.buttonMask = 0x1 + self.addGestureRecognizer(recognizer) + self.hasClickRecognizer = true + } + required init(rootView: Content) { self.highlightState = MenuCardHighlightState() self.onClick = nil diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift new file mode 100644 index 000000000..c8f0e1f1a --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -0,0 +1,132 @@ +import AppKit + +/// Pre-harvest snapshot of one live content row, captured before card views are detached +/// into the recycle pool so reconciliation can still compare row shapes afterwards. +struct MenuRowShape { + let isSeparator: Bool + let id: String? + let viewClassName: String? +} + +extension StatusItemController { + func menuContentShapes(in menu: NSMenu, fromIndex: Int) -> [MenuRowShape] { + guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] } + return menu.items[fromIndex...].map { item in + MenuRowShape( + isSeparator: item.isSeparatorItem, + id: item.representedObject as? String, + viewClassName: item.view.map { String(describing: type(of: $0)) }) + } + } + + /// Position-wise in-place reconciliation: live rows whose shape matches the freshly + /// built content (separator placement, card identifier, view class) are updated in + /// place — views transplanted, plain rows recopied — and only the mismatched middle + /// span is removed and reinserted. Matching runs from both ends, so the expensive card + /// rows at the top and the shared action rows at the bottom survive even a provider + /// switch whose middle sections differ; AppKit then relayouts the open tracked menu for + /// the few changed rows instead of once per row. + func reconcileMenuContent( + _ menu: NSMenu, + fromIndex: Int, + shapes: [MenuRowShape], + with scratch: NSMenu) + { + defer { self.finishReconciledHighlightTracking(in: menu) } + let newItems = scratch.items + scratch.removeAllItems() + guard menu.items.count - fromIndex == shapes.count else { + // The live region changed underneath the snapshot; replace it wholesale. + self.replaceMenuContent(menu, fromIndex: fromIndex, with: newItems) + return + } + + func updatable(_ shape: MenuRowShape, _ newItem: NSMenuItem) -> Bool { + guard shape.isSeparator == newItem.isSeparatorItem else { return false } + if shape.isSeparator { return true } + guard shape.id == newItem.representedObject as? String else { return false } + return shape.viewClassName == newItem.view.map { String(describing: type(of: $0)) } + } + + var prefix = 0 + while prefix < min(shapes.count, newItems.count), updatable(shapes[prefix], newItems[prefix]) { + prefix += 1 + } + var suffix = 0 + while suffix < min(shapes.count, newItems.count) - prefix, + updatable(shapes[shapes.count - 1 - suffix], newItems[newItems.count - 1 - suffix]) + { + suffix += 1 + } + + for offset in 0.. fromIndex { + menu.removeItem(at: fromIndex) + } + for item in newItems { + menu.addItem(item) + } + } + + private func updateMenuItemInPlace(_ liveItem: NSMenuItem, from newItem: NSMenuItem) { + if liveItem.isSeparatorItem { return } + let remainsHighlighted = liveItem.menu.map { + self.highlightedMenuItems[ObjectIdentifier($0)] === liveItem + } ?? false + // Detach from the scratch item first so a view or submenu is never referenced by + // two menu items at once. + let view = newItem.view + newItem.view = nil + let submenu = newItem.submenu + newItem.submenu = nil + liveItem.view = view + (view as? MenuCardHighlighting)?.setHighlighted(remainsHighlighted) + liveItem.submenu = submenu + liveItem.title = newItem.title + liveItem.attributedTitle = newItem.attributedTitle + liveItem.action = newItem.action + liveItem.target = newItem.target + liveItem.representedObject = newItem.representedObject + liveItem.state = newItem.state + liveItem.isEnabled = newItem.isEnabled + liveItem.image = newItem.image + liveItem.toolTip = newItem.toolTip + liveItem.keyEquivalent = newItem.keyEquivalent + liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask + liveItem.indentationLevel = newItem.indentationLevel + if #available(macOS 14.4, *) { + liveItem.subtitle = newItem.subtitle + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index f9aab228d..a6b21ed34 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -120,6 +120,7 @@ extension StatusItemController { [ provider.rawValue, "token=\(tokenSignature)", + "refreshing=\(self.store.shouldShowRefreshingMenuCardIndicator(for: provider) ? "1" : "0")", "usageHistory=\(usageHistoryVisible ? "1" : "0")", ].joined(separator: ":")) } @@ -127,7 +128,7 @@ extension StatusItemController { return parts.joined(separator: "|") } - private static func dashboardBreakdownReadinessSignature( + static func dashboardBreakdownReadinessSignature( _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String { breakdown @@ -214,6 +215,7 @@ extension StatusItemController { _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + resyncReadinessBaselineAfterRebuild: Bool = false, debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { @@ -255,6 +257,9 @@ extension StatusItemController { self.closeHostedSubviewMenusForParentSwitch() } self.rebuildOpenMenuIfStillVisible(menu, provider: provider) + if resyncReadinessBaselineAfterRebuild, !self.menuNeedsRefresh(menu) { + self.resyncMenuAdjunctReadinessBaseline() + } } } diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift new file mode 100644 index 000000000..c55794f4b --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -0,0 +1,137 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + struct MenuUpdateContext { + let provider: UsageProvider? + let currentProvider: UsageProvider + let switcherSelection: ProviderSwitcherSelection + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } + + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + func updateMenuContentPreservingSwitcher( + _ menu: NSMenu, + context: MenuUpdateContext) + { + self.performMenuMutationWithoutAnimation { + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) + if let switcherView = menu.items.first?.view as? ProviderSwitcherView { + switcherView.updateSelection(context.switcherSelection) + switcherView.updateQuotaIndicators() + } + let outgoingSelection = self.lastMergedMenuContentSelection + let isSelectionSwitch = outgoingSelection != nil && outgoingSelection != context.switcherSelection + let enabledProviders = self.store.enabledProvidersForDisplay() + + if isSelectionSwitch, + let outgoingSelection, + self.hasReusableMergedSwitcherContent( + for: context.switcherSelection, + in: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + // Instant path: the incoming tab reattaches wholesale, so park the outgoing + // items for an equally instant switch-back. + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: outgoingSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth) + while menu.items.count > contentStartIndex { + menu.removeItem(at: contentStartIndex) + } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + if self.addCachedMergedSwitcherContent( + for: context.switcherSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } + self.addSwitcherScopedMenuContent(into: menu, captureMenu: menu, context: context) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + return + } + + // Rebuild path (data tick, or switch whose incoming tab must be built): recycle + // the outgoing hosting views and reconcile in place when the row skeleton is + // unchanged, so an open tracked menu sees content mutations instead of item + // churn. The fresh content is built into a detached scratch menu while its + // interaction closures capture the live menu they will serve. + let shapes = self.menuContentShapes(in: menu, fromIndex: contentStartIndex) + self.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: contentStartIndex, + displacedSelection: outgoingSelection, + preserveHighlightedItem: true) + defer { self.clearMenuCardViewRecyclePool() } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + } + } + + /// Adds everything below the provider switcher (account switchers, card content, and + /// actionable sections) to `target`, which may be a detached scratch menu; interaction + /// closures always capture `captureMenu`, the live menu the rows will serve. + private func addSwitcherScopedMenuContent( + into target: NSMenu, + captureMenu: NSMenu, + context: MenuUpdateContext) + { + self.addCodexAccountSwitcherIfNeeded( + to: target, + display: context.codexAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: target, + display: context.tokenAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: target, + context: menuContext, + switcherSelection: context.switcherSelection, + captureMenu: captureMenu) + self.addActionableSections( + context.descriptor.sections, + to: target, + width: context.menuWidth, + captureMenu: captureMenu) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 2df363724..7601eeb03 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -37,6 +37,11 @@ extension StatusItemController { self.clearMergedSwitcherContentCaches() } self.pruneVersionScopedMenuCardHeightCache() + if allowStaleContentDuringDataRefresh { + self.latestDataOnlyMenuContentVersion = self.menuContentVersion + } else { + self.latestStructuralMenuContentVersion = self.menuContentVersion + } if !allowStaleContentDuringDataRefresh, !preservesMergedSwitcherContentCaches { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } @@ -49,31 +54,56 @@ extension StatusItemController { deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) return } + if allowStaleContentDuringDataRefresh { + if !self.cancelNonRequiredClosedMenuPreparation() { + self.prepareAttachedClosedMenusIfNeeded() + } + return + } self.prepareAttachedClosedMenusIfNeeded() } + @discardableResult + private func cancelNonRequiredClosedMenuPreparation() -> Bool { + let menus = self.attachedMenusForClosedPreparation() + let hasRequiredClosedMenu = self.latestRequiredMenuRebuildVersion > 0 && menus.contains { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + } + guard !hasRequiredClosedMenu else { return false } + self.cancelAllClosedMenuRebuilds() + for menu in menus { + self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) + } + return true + } + func prepareAttachedClosedMenusIfNeeded() { guard self.isMenuRefreshEnabled else { return } guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } let menus = self.attachedMenusForClosedPreparation() let requiredClosedPreparationVersion: Int? - if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { - guard self.latestRequiredMenuRebuildVersion > 0 else { return } - let hasRequiredClosedMenu = menus.contains { menu in - let key = ObjectIdentifier(menu) - return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion - } - guard hasRequiredClosedMenu else { return } + if self.latestRequiredMenuRebuildVersion > 0, + menus.contains(where: { menu in + let key = ObjectIdentifier(menu) + return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion + }) + { requiredClosedPreparationVersion = self.latestRequiredMenuRebuildVersion + } else if self.menuContentVersion > self.latestRequiredMenuRebuildVersion { + guard self.latestRequiredMenuRebuildVersion > 0 else { return } + return } else { requiredClosedPreparationVersion = nil } for menu in menus { let key = ObjectIdentifier(menu) - guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } if let requiredClosedPreparationVersion { + self.closedMenusDeferredUntilNextOpen.remove(key) guard (self.menuVersions[key] ?? -1) < requiredClosedPreparationVersion else { continue } + } else { + guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue } } // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display @@ -96,6 +126,7 @@ extension StatusItemController { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) self.menuReadinessSignatures.removeValue(forKey: key) + self.menuIdentitySignatures.removeValue(forKey: key) self.closedMenusDeferredUntilNextOpen.remove(key) } @@ -112,28 +143,36 @@ extension StatusItemController { func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) guard self.menuNeedsRefresh(menu) else { return } - if self.canPreserveStaleMenuContentDuringRefresh(menu) { + if self.canPreserveStaleMenuContentForInstantOpen(menu) { #if DEBUG self.menuLogger.debug( - "menu open kept existing content during refresh", + "menu open kept existing content for instant render", metadata: [ "items": "\(menu.items.count)", "provider": provider?.rawValue ?? "nil", "storeRefreshing": self.store.isRefreshing ? "1" : "0", ]) #endif - self.deferMenuInteractionRefreshIfNeeded() + if self.isMenuRefreshEnabled, !self.isMenuDataRefreshInFlight { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: provider, + resyncReadinessBaselineAfterRebuild: self.openMenus.isEmpty) + } return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) } - private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool { - guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false } + private func canPreserveStaleMenuContentForInstantOpen(_ menu: NSMenu) -> Bool { + guard !menu.items.isEmpty else { return false } let key = ObjectIdentifier(menu) guard let menuVersion = self.menuVersions[key] else { return false } - return menuVersion >= self.latestRequiredMenuRebuildVersion + return self.menuContentVersion == self.latestDataOnlyMenuContentVersion && + menuVersion >= self.latestStructuralMenuContentVersion && + self.menuIdentitySignatures[key] == self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) } private func attachedMenusForClosedPreparation() -> [NSMenu] { @@ -160,8 +199,29 @@ extension StatusItemController { } func renderedMenuWidth(for menu: NSMenu) -> CGFloat { - let measuredWidth = ceil(menu.size.width) - return max(measuredWidth, Self.menuCardBaseWidth) + let menuKey = ObjectIdentifier(menu) + let trackedWindowWidth: CGFloat? = if self.openMenus[menuKey] != nil { + menu.items.lazy.compactMap { item -> CGFloat? in + guard let window = item.view?.window else { return nil } + let contentWidth = window.contentLayoutRect.width + return contentWidth > 0 ? contentWidth : window.frame.width + }.first + } else { + nil + } + return Self.resolvedRenderedMenuWidth( + menuWidth: menu.size.width, + trackedWindowWidth: trackedWindowWidth) + } + + static func resolvedRenderedMenuWidth( + menuWidth: CGFloat, + trackedWindowWidth: CGFloat?) -> CGFloat + { + max( + ceil(menuWidth), + ceil(trackedWindowWidth ?? 0), + menuCardBaseWidth) } func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { @@ -233,6 +293,66 @@ extension StatusItemController { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature() + self.menuIdentitySignatures[key] = self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) + } + + private func menuIdentitySignature(for providers: [UsageProvider]) -> String { + var parts: [String] = [] + for target in providers { + parts.append(target.rawValue) + parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target))) + + if target != .codex, self.store.metadata(for: target).usesAccountFallback { + let account = self.store.accountInfo(for: target) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.plan)) + } + + for accountSnapshot in self.store.accountSnapshots[target] ?? [] { + parts.append(accountSnapshot.account.id.uuidString) + parts.append(Self.menuIdentityField(accountSnapshot.account.label)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + + if target == .codex { + parts.append(Self.menuIdentityField(self.account.email)) + parts.append(Self.menuIdentityField(self.account.plan)) + for account in self.settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts ?? [] { + parts.append(Self.menuIdentityField(account.id)) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.workspaceLabel)) + parts.append(account.isActive ? "active" : "inactive") + parts.append(account.isLive ? "live" : "stored") + } + for accountSnapshot in self.store.codexAccountSnapshots { + parts.append(Self.menuIdentityField(accountSnapshot.id)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + } + + if target == .kilo { + for scopeSnapshot in self.store.kiloScopeSnapshots { + parts.append(Self.menuIdentityField(scopeSnapshot.id)) + parts.append(self.providerIdentitySignature(scopeSnapshot.snapshot?.identity(for: target))) + } + } + } + return parts.joined(separator: "|") + } + + private func providerIdentitySignature(_ identity: ProviderIdentitySnapshot?) -> String { + [ + identity?.providerID?.rawValue ?? "", + Self.menuIdentityField(identity?.accountEmail), + Self.menuIdentityField(identity?.accountOrganization), + Self.menuIdentityField(identity?.loginMethod), + ].joined(separator: ":") + } + + private static func menuIdentityField(_ value: String?) -> String { + let value = value ?? "" + return "\(value.utf8.count):\(value)" } func hasOpenHostedSubviewMenu() -> Bool { @@ -240,14 +360,25 @@ extension StatusItemController { } func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } + if self.isHostedSubviewMenu(menu) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.applyIcon(phase: nil) #if DEBUG self._test_openMenuRebuildObserver?(menu) diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index a376958e1..8f18e5266 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -1,8 +1,51 @@ import AppKit +import CodexBarCore extension StatusItemController { private static let measuredStandardMenuWidthCacheLimit = 96 + func menuCardWidth( + for providers: [UsageProvider], + selectedProvider: UsageProvider?, + descriptor: MenuDescriptor) -> CGFloat + { + let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { + providers.map { provider in + if provider == selectedProvider { + return descriptor.sections + } + return self.makeMenuDescriptor( + provider: provider, + includeContextualActions: true).sections + } + } else { + [descriptor.sections] + } + return self.measuredMenuCardWidth(for: sectionSets) + } + + func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { + let baselineWidth = Self.menuCardBaseWidth + return sectionSets.reduce(baselineWidth) { width, sections in + max(width, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) + } + } + + func makeMenuDescriptor( + provider: UsageProvider?, + includeContextualActions: Bool) -> MenuDescriptor + { + MenuDescriptor.build( + provider: provider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: includeContextualActions) + } + func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { let cacheKey = self.measuredStandardMenuWidthCacheKey(for: sections, baseWidth: baseWidth) if let cached = self.measuredStandardMenuWidthCache[cacheKey] { diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index b31c447c9..5f323f108 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -65,6 +65,32 @@ extension StatusItemController { self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry } + /// Non-consuming variant of `addCachedMergedSwitcherContent`'s lookup: reports whether a + /// reusable entry exists (evicting it when stale) without attaching anything, so callers + /// can choose between reattaching cached content and recycling the outgoing views. + func hasReusableMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + in menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false } + guard entry.matches( + requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return false + } + return true + } + func addCachedMergedSwitcherContent( for selection: ProviderSwitcherSelection, to menu: NSMenu, diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index 4d3444edd..ef9a6667c 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -53,7 +53,8 @@ extension StatusItemController { self.lastMenuProvider = provider self.refreshProviderSelectionDependentUI(deferRendering: true) } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) + // Custom-view clicks stay open and rebuild next turn. Standard menu-item activation can close; + // menuWillOpen then renders the saved provider without doing structural work inside the action. + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 6611fdd52..ca096bb6f 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore extension StatusItemController { @@ -34,7 +35,10 @@ extension StatusItemController { self.applyIcon(phase: phase) } - func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { + func navigateProviderSwitcher( + _ direction: StatusItemMenuProviderNavigationDirection, + menu: NSMenu? = nil) + { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() guard enabledProviders.count > 1 else { return } @@ -59,6 +63,12 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] + let menuProvider: UsageProvider = switch selection { + case .overview: + self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + provider + } self.preservingMergedSwitcherContentCachesDuringInvalidation { switch selection { case .overview: @@ -70,7 +80,13 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection - self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true) + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + let trackedMenu = menu ?? self.providerSwitcherShortcutMenuID.flatMap { self.openMenus[$0] } + if let trackedMenu { + self.requestProviderSwitcherMenuRebuild( + trackedMenu, + provider: menuProvider) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 034d1699e..b9c6df97f 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -6,13 +6,83 @@ struct PendingProviderSwitcherRebuild { let provider: UsageProvider? } +/// Skips the event-queue peek on run-loop passes where no event of the monitored kinds +/// can possibly be pending. The menu-tracking run loop spins on every mouse move, and the +/// session-wide event counters for keys and clicks are far cheaper to read than +/// `NSApp.nextEvent` is to call, so gating on them removes the per-pass peek cost from +/// hover-heavy menu interaction (mouse moves never advance these counters). +@MainActor +final class ProviderSwitcherEventPeekGate { + private let eventTypes: [CGEventType] + private let counterProvider: (CGEventType) -> UInt32 + private var lastCounters: [UInt32]? + private var heldKeyCodes: Set = [] + private var emptyPeekBudget = 0 + + init( + eventTypes: [CGEventType], + counterProvider: @escaping (CGEventType) -> UInt32 = { type in + CGEventSource.counterForEventType(.combinedSessionState, eventType: type) + }) + { + self.eventTypes = eventTypes + self.counterProvider = counterProvider + } + + /// True when an event of a monitored kind may have been posted since the last check. + func shouldPeek() -> Bool { + let counters = self.eventTypes.map(self.counterProvider) + let countersChanged = self.lastCounters.map { counters != $0 } ?? true + self.lastCounters = counters + if countersChanged { + // The observer runs before run-loop sources. WindowServer can advance a counter + // one pass before AppKit queues the NSEvent, so require two empty peeks before + // considering the queue caught up. + self.emptyPeekBudget = max(self.emptyPeekBudget, 2) + } + // CoreGraphics does not count key autorepeat events. Keep peeking while a key is + // held so repeated provider-navigation events are still handled. + if !self.heldKeyCodes.isEmpty { return true } + return self.emptyPeekBudget > 0 + } + + func observe(_ event: NSEvent) { + // An unhandled event stays queued until AppKit processes it after this observer. + // Keep peeking until a later pass proves the matching queue is empty. + self.emptyPeekBudget = max(self.emptyPeekBudget, 1) + switch event.type { + case .keyDown: + self.heldKeyCodes.insert(event.keyCode) + case .keyUp: + self.heldKeyCodes.remove(event.keyCode) + default: + break + } + } + + func observeQueueEmpty(afterFindingEvent: Bool) { + if afterFindingEvent { + // A counter snapshot can represent multiple events that AppKit delivers across + // run-loop passes. Keep one empty proof pending after draining available events. + self.emptyPeekBudget = max(self.emptyPeekBudget - 1, 1) + } else if self.emptyPeekBudget > 0 { + self.emptyPeekBudget -= 1 + } + } +} + @MainActor final class ProviderSwitcherShortcutEventMonitor { private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver private var isActive = false - init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { + init( + events: NSEvent.EventTypeMask, + peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]), + callback: @escaping @MainActor (NSEvent) -> Bool) + { self.callback = callback self.observer = CFRunLoopObserverCreateWithHandler( @@ -20,21 +90,32 @@ final class ProviderSwitcherShortcutEventMonitor { CFRunLoopActivity.beforeSources.rawValue, true, 0) - { [events, callback] _, _ in + { [events, peekGate, callback] _, _ in MainActor.assumeIsolated { + guard peekGate.shouldPeek() else { return } + var foundEvent = false + var blockedByUnhandledEvent = false while let event = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: false) { - guard callback(event) else { break } + foundEvent = true + peekGate.observe(event) + guard callback(event) else { + blockedByUnhandledEvent = true + break + } _ = NSApp.nextEvent( matching: events, until: .distantPast, inMode: .eventTracking, dequeue: true) } + if !blockedByUnhandledEvent { + peekGate.observeQueueEmpty(afterFindingEvent: foundEvent) + } } } } @@ -75,7 +156,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() let monitor = ProviderSwitcherShortcutEventMonitor( - events: [.keyDown, .leftMouseDown, .leftMouseUp]) + events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]) { [weak self, weak menu] event in guard let self, let menu, @@ -109,7 +190,7 @@ extension StatusItemController { return self.selectProviderSwitcherSegment(at: index, menu: menu) } if let direction = StatusItemMenu.providerNavigationDirection(for: event) { - self.navigateProviderSwitcher(direction) + self.navigateProviderSwitcher(direction, menu: menu) return true } return false diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 6e44db4e0..6494086c9 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -52,8 +52,11 @@ extension StatusItemController { } self.openMenuInvalidationRetryTask?.cancel() self.openMenuInvalidationRetryTask = nil + self.codexAccountMenuProjectionRevalidationTask?.cancel() + self.codexAccountMenuProjectionRevalidationTask = nil self.providerSelectionUIRefreshTask?.cancel() self.providerSelectionUIRefreshTask = nil + self.deferredMergedIconRenderAfterTracking = false self.providerSwitcherPointerInteractionMenuID = nil self.pendingProviderSwitcherPointerRebuild = nil } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5e8222001..e46d21a66 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -116,8 +116,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 + var latestDataOnlyMenuContentVersion: Int = 0 + var latestStructuralMenuContentVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var menuReadinessSignatures: [ObjectIdentifier: String] = [:] + let hostedSubviewRenderSignatures = NSMapTable.weakToStrongObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" @@ -135,9 +138,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 + var menuIdentitySignatures: [ObjectIdentifier: String] = [:] + var codexAccountMenuProjectionRevalidationTask: Task? var openMenuRebuildsClosingHostedSubviewMenus: Set = [] var parentMenuRebuildsDeferredDuringTracking: Set = [] - var deferredMenuInteractionRefreshPending = false + var deferredMenuInteractionRefreshProviders: Set = [] + var deferredMenuInteractionRefreshPending: Bool { + !self.deferredMenuInteractionRefreshProviders.isEmpty + } + var deferredOpenAIDashboardRefreshReason: String? var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] @@ -234,9 +243,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] = [:] var preservesMergedSwitcherContentCachesDuringInvalidation = false + /// Card hosting views harvested from items about to be discarded by the current populate + /// pass, keyed by card identifier; consumed by `makeMenuCardItem` and cleared when the + /// pass finishes. Never outlives a single synchronous menu population. + var menuCardViewRecyclePool: [String: NSView] = [:] /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var providerSelectionUIRefreshTask: Task? + var deferredMergedIconRenderAfterTracking = false var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] var lastObservedStoreIconWorkSignature: String? @@ -402,6 +416,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.wireBindings() self.updateVisibility() self.updateIcons() + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.store.enabledProvidersForDisplay()) self.scheduleStartupStatusItemVisibilityCheck() NotificationCenter.default.addObserver( self, @@ -556,26 +572,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) } - func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { - let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) - self.quotaWarningFlashUntil[provider] = until - self.quotaWarningFlashTasks[provider]?.cancel() - self.updateIcons() - self.quotaWarningFlashTasks[provider] = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) - await MainActor.run { [weak self] in - guard let self else { return } - if let currentUntil = self.quotaWarningFlashUntil[provider], - currentUntil <= Date() - { - self.quotaWarningFlashUntil.removeValue(forKey: provider) - self.quotaWarningFlashTasks.removeValue(forKey: provider) - self.updateIcons() - } - } - } - } - private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady @@ -644,6 +640,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder + let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() if orderChanged || configChanged { @@ -652,11 +649,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.updateVisibility() self.updateIcons() if shouldRefreshOpenMenus { - self.refreshOpenMenusForStructureChange() + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: !localizationChanged) } } - private func updateIcons() { + func updateIcons() { #if DEBUG guard !self.isReleasedForTesting else { return } #endif @@ -669,6 +667,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.shouldMergeIcons { let skippedMergedRender = self.applyIcon(phase: phase) if skippedMergedRender, + !self.deferredMergedIconRenderAfterTracking, let mergedMenu = self.mergedMenu, self.statusItem.menu === mergedMenu { diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 6e26c9850..4e20eb66d 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -191,17 +191,14 @@ struct StoragePathCopyButton: View { var body: some View { Button { - Self.copyToPasteboard(self.path) - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { + MenuPasteboardCopy.perform(self.path, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) self.didCopy = false } - } + }) } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -213,10 +210,4 @@ struct StoragePathCopyButton: View { .help(self.didCopy ? L("Copied") : L("Copy path")) .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy path")) } - - static func copyToPasteboard(_ path: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(path, forType: .string) - } } diff --git a/Sources/CodexBar/Sync/SyncCoordinator.swift b/Sources/CodexBar/Sync/SyncCoordinator.swift index 56178efab..e507937c6 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator.swift @@ -1904,7 +1904,10 @@ final class SyncCoordinator { // (deployment validation), Alibaba Token Plan (Bailian quota), // and T3 Chat (web session) all surface pre-computed numbers // from their own APIs — never via the local pricing tables. - .azureopenai, .alibabatokenplan, .t3chat: + .azureopenai, .alibabatokenplan, .t3chat, + // Upstream 0.33 new provider. Devin quota numbers come from + // its own API — never via the local pricing tables. + .devin: // These providers never reach the local pricing table — their // costs come pre-computed from upstream APIs (or don't exist). // No fallback applies, so they are never "estimated". diff --git a/Sources/CodexBar/TerminalApp.swift b/Sources/CodexBar/TerminalApp.swift new file mode 100644 index 000000000..2db1fdd52 --- /dev/null +++ b/Sources/CodexBar/TerminalApp.swift @@ -0,0 +1,57 @@ +import AppKit + +enum TerminalApp: String, CaseIterable, Identifiable { + case terminal + case iTerm + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .terminal: "Terminal" + case .iTerm: "iTerm" + } + } + + var bundleIdentifier: String { + switch self { + case .terminal: "com.apple.Terminal" + case .iTerm: "com.googlecode.iterm2" + } + } + + var isInstalled: Bool { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: self.bundleIdentifier) != nil + } + + func appleScript(command: String) -> String { + let escaped = Self.escapeForAppleScript(command) + return switch self { + case .terminal: + """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + case .iTerm: + """ + tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text "\(escaped)" + end tell + end tell + """ + } + } + + static func escapeForAppleScript(_ command: String) -> String { + command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/CodexBar/UsageStore+APIKeyDebug.swift b/Sources/CodexBar/UsageStore+APIKeyDebug.swift new file mode 100644 index 000000000..00e04fad5 --- /dev/null +++ b/Sources/CodexBar/UsageStore+APIKeyDebug.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation + +// MARK: - API key debug contexts + +// Extracted from the debug-dump extension in UsageStore.swift to keep that +// file under the SwiftLint file_length limit. `private` became internal in +// the move; these remain debug-only helpers for `debugLog(for:)`. + +@MainActor +extension UsageStore { + struct APIKeyDebugContext { + let label: String + let resolution: ProviderTokenResolution? + let configToken: String? + let hasEnvToken: Bool + let hasTokenAccount: Bool + } + + func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openai) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openai, + config: config) + return APIKeyDebugContext( + label: "OPENAI_API_KEY", + resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .azureopenai) + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: processEnvironment, + provider: .azureopenai, + config: config) + return APIKeyDebugContext( + label: "AZURE_OPENAI_API_KEY", + resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openrouter) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openrouter, + config: config) + return APIKeyDebugContext( + label: "OPENROUTER_API_KEY", + resolution: ProviderTokenResolver.openRouterResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .elevenlabs) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .elevenlabs, + config: config) + return APIKeyDebugContext( + label: "ELEVENLABS_API_KEY", + resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index ad01e7460..988e25257 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -36,6 +36,12 @@ extension UsageStore { } func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationSeriesHistory] { + self.planUtilizationHistorySelection(for: provider).histories + } + + func planUtilizationHistorySelection(for provider: UsageProvider) + -> (accountKey: String?, histories: [PlanUtilizationSeriesHistory]) + { var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let originalProviderBuckets = providerBuckets let accountKey = self.resolvePlanUtilizationAccountKey( @@ -50,7 +56,7 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } } - return providerBuckets.histories(for: accountKey) + return (accountKey, providerBuckets.histories(for: accountKey)) } func codexPlanUtilizationHistories(forVisibleAccount account: CodexVisibleAccount) @@ -88,6 +94,11 @@ extension UsageStore { && self.error(for: provider) == nil } + func shouldShowRefreshingMenuCardIndicator(for provider: UsageProvider) -> Bool { + let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) + return isRefreshing && self.error(for: provider) == nil + } + func shouldHidePlanUtilizationMenuItem(for provider: UsageProvider) -> Bool { guard self.supportsPlanUtilizationHistory(for: provider) else { return true } return self.shouldShowRefreshingMenuCard(for: provider) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index eabe08c78..ed33360cf 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,6 +2,19 @@ import CodexBarCore import Foundation extension UsageStore { + private struct ProviderRefreshOutcomeContext { + let generation: UInt64 + let codexExpectedGuard: CodexAccountScopedRefreshGuard? + let claudeCredentialsChanged: Bool + let shouldConsumeClaudeKeychainFingerprint: Bool + } + + func refreshForSettingsChange() async { + await self.runRefresh( + startupConnectivityRetryAttempt: nil, + coalesceProviderRefreshesOverride: false) + } + func prepareRefreshState(for provider: UsageProvider? = nil) { guard provider == nil || provider == .codex else { return } _ = self.settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() @@ -20,9 +33,140 @@ extension UsageStore { return self.providerSpecs[provider] } - func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { + func refreshProvider( + _ provider: UsageProvider, + allowDisabled: Bool = false, + coalesceIfRefreshing: Bool = false) async + { + while coalesceIfRefreshing, + let states = self.providerRefreshTasks[provider], + let latestGeneration = self.latestProviderRefreshGenerations[provider], + let existingState = states.last(where: { $0.generation == latestGeneration }) + { + await self.waitForProviderRefresh(provider, state: existingState) + if Task.isCancelled { return } + if existingState.shouldRetry { + self.removeProviderRefreshTask(provider, state: existingState) + continue + } + return + } + + self.providerRefreshTaskGeneration &+= 1 + let generation = self.providerRefreshTaskGeneration + let predecessorStates = self.providerRefreshTasks[provider] ?? [] + for predecessorState in predecessorStates { + predecessorState.cancelTask() + } + self.latestProviderRefreshGenerations[provider] = generation + let state = ProviderRefreshTaskState(generation: generation) + let task = Task { @MainActor [weak self] in + guard let self else { return } + var snapshotUpdatedAtBeforeRefresh: Date? + var didStartRefresh = false + for predecessorState in predecessorStates { + await predecessorState.waitForTaskCompletion() + } + if !Task.isCancelled, self.isCurrentProviderRefreshGeneration(provider, generation: generation) { + snapshotUpdatedAtBeforeRefresh = self.snapshot(for: provider)?.updatedAt + didStartRefresh = true + await self.refreshProviderTracked( + provider, + allowDisabled: allowDisabled, + generation: generation) + } + let publishedNewSnapshot = didStartRefresh && + self.snapshot(for: provider)?.updatedAt != snapshotUpdatedAtBeforeRefresh + let retryRequired = Task.isCancelled && !publishedNewSnapshot + self.providerRefreshDidComplete(provider, state: state, retryRequired: retryRequired) + } + state.install(task: task) + self.providerRefreshTasks[provider, default: []].append(state) + await self.waitForProviderRefresh(provider, state: state) + } + + private func waitForProviderRefresh(_ provider: UsageProvider, state: ProviderRefreshTaskState) async { + self.providerRefreshWaiterGeneration &+= 1 + let waiterID = self.providerRefreshWaiterGeneration + guard let task = state.addWaiter(waiterID) else { return } + await withTaskCancellationHandler { + await task.value + } onCancel: { + state.cancelWaiter(waiterID) + } + state.finishWaiter(waiterID) + if state.canRemove { + self.scheduleProviderRefreshTaskRemoval(provider, state: state) + } + } + + private func providerRefreshDidComplete( + _ provider: UsageProvider, + state: ProviderRefreshTaskState, + retryRequired: Bool) + { + state.markCompleted(retryRequired: retryRequired) + self.scheduleProviderRefreshTaskRemoval(provider, state: state) + } + + private func removeProviderRefreshTask(_ provider: UsageProvider, state: ProviderRefreshTaskState) { + guard var states = self.providerRefreshTasks[provider] else { return } + states.removeAll { $0 === state } + if states.isEmpty { + self.providerRefreshTasks.removeValue(forKey: provider) + } else { + self.providerRefreshTasks[provider] = states + } + } + + private func scheduleProviderRefreshTaskRemoval(_ provider: UsageProvider, state: ProviderRefreshTaskState) { + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + self.providerRefreshTasks[provider]?.contains(where: { $0 === state }) == true, + state.canRemove + else { + return + } + self.removeProviderRefreshTask(provider, state: state) + } + } + + func isCurrentProviderRefreshGeneration(_ provider: UsageProvider, generation: UInt64?) -> Bool { + guard let generation else { return true } + return self.latestProviderRefreshGenerations[provider] == generation + } + + private func refreshProviderTracked( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { + self.providerRefreshCounts[provider, default: 0] += 1 + self.refreshingProviders.insert(provider) + defer { + let remaining = max(0, self.providerRefreshCounts[provider, default: 1] - 1) + if remaining == 0 { + self.providerRefreshCounts.removeValue(forKey: provider) + self.refreshingProviders.remove(provider) + } else { + self.providerRefreshCounts[provider] = remaining + } + } + await self.refreshProviderNow( + provider, + allowDisabled: allowDisabled, + generation: generation) + } + + private func refreshProviderNow( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { self.prepareRefreshState(for: provider) guard let spec = await self.providerRefreshSpec(provider) else { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let codexExpectedGuard = provider == .codex ? self.freshCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { @@ -30,18 +174,16 @@ extension UsageStore { return } - self.refreshingProviders.insert(provider) - defer { self.refreshingProviders.remove(provider) } - if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { - await self.refreshCodexVisibleAccountsForMenu() + await self.refreshCodexVisibleAccountsForMenu(generation: generation) return } else if provider == .codex { self.codexAccountSnapshots = [] } if provider == .kilo, self.shouldFanOutKiloScopes() { - await self.refreshKiloScopes() + await self.refreshKiloScopes(generation: generation) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } // Continue to also fetch the personal snapshot through the regular path // so the existing single-card render keeps working when only personal is shown. // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. @@ -51,7 +193,10 @@ extension UsageStore { let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { - await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + await self.refreshTokenAccounts( + provider: provider, + accounts: tokenAccounts, + generation: generation) return } else { _ = await MainActor.run { @@ -62,7 +207,7 @@ extension UsageStore { let claudeAuthStateBeforeFetch = provider == .claude ? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true) : nil - let fetchContext = spec.makeFetchContext() + let fetchContext = self.makeFetchContext(provider: provider, override: nil) let descriptor = spec.descriptor // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. let outcome = await withTaskGroup( @@ -74,6 +219,7 @@ extension UsageStore { } return await group.next()! } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let claudeAuthFingerprintAfterFetch = provider == .claude ? await Self.captureClaudeAuthFingerprintToken() : nil @@ -88,6 +234,22 @@ extension UsageStore { let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange( beforeFetch: claudeAuthStateBeforeFetch, changedDuringFetch: claudeAuthChangedDuringFetch) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + await self.applyProviderRefreshOutcome( + provider: provider, + outcome: outcome, + context: ProviderRefreshOutcomeContext( + generation: generation, + codexExpectedGuard: codexExpectedGuard, + claudeCredentialsChanged: claudeCredentialsChanged, + shouldConsumeClaudeKeychainFingerprint: shouldConsumeClaudeKeychainFingerprint)) + } + + private func applyProviderRefreshOutcome( + provider: UsageProvider, + outcome: ProviderFetchOutcome, + context: ProviderRefreshOutcomeContext) async + { await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } @@ -96,17 +258,20 @@ extension UsageStore { case let .success(result): let scoped = result.usage.scoped(to: provider) if provider == .codex, - let codexExpectedGuard, + let codexExpectedGuard = context.codexExpectedGuard, !self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: scoped) { return } - let backfilled = await MainActor.run { - if claudeCredentialsChanged { + let backfilled = await MainActor.run { () -> UsageSnapshot? in + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { + return nil + } + if context.claudeCredentialsChanged { self.clearClaudeCredentialDerivedStateForCredentialSwapNow() } let resetBackfillSource = provider == .codex - ? self.codexLastKnownResetSnapshot(matching: codexExpectedGuard) + ? self.codexLastKnownResetSnapshot(matching: context.codexExpectedGuard) : self.lastKnownResetSnapshots[provider] let backfilled = scoped.backfillingResetTimes(from: resetBackfillSource) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) @@ -130,12 +295,14 @@ extension UsageStore { } return backfilled } - if shouldConsumeClaudeKeychainFingerprint { + guard let backfilled else { return } + if context.shouldConsumeClaudeKeychainFingerprint { _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() } await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: backfilled) + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) @@ -146,19 +313,23 @@ extension UsageStore { } case let .failure(error): if provider == .codex, - let codexExpectedGuard, + let codexExpectedGuard = context.codexExpectedGuard, !self.shouldApplyCodexScopedFailure(expectedGuard: codexExpectedGuard) { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } self.recordStartupConnectivityRetryableFailure(error) - if claudeCredentialsChanged { + if context.claudeCredentialsChanged { await self.clearClaudeCredentialDerivedStateForCredentialSwap() } - if shouldConsumeClaudeKeychainFingerprint { + if context.shouldConsumeClaudeKeychainFingerprint { _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() } - await self.handleProviderFetchFailure(provider: provider, error: error) + await self.handleProviderFetchFailure( + provider: provider, + error: error, + generation: context.generation) } } @@ -293,9 +464,14 @@ extension UsageStore { self.lastTokenFetchAt.removeValue(forKey: .claude) } - private func handleProviderFetchFailure(provider: UsageProvider, error: Error) async { + private func handleProviderFetchFailure( + provider: UsageProvider, + error: Error, + generation: UInt64) async + { let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } let hadPriorData = self.snapshots[provider] != nil let preservesPriorData = Self.shouldPreservePriorSnapshot( after: error, @@ -336,6 +512,7 @@ extension UsageStore { self.postPermissionPromptNotificationIfNeeded(provider: provider, error: error) } } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) diff --git a/Sources/CodexBar/UsageStore+Status.swift b/Sources/CodexBar/UsageStore+Status.swift index 4d1b85e4d..c1bd9b3fb 100644 --- a/Sources/CodexBar/UsageStore+Status.swift +++ b/Sources/CodexBar/UsageStore+Status.swift @@ -1,8 +1,51 @@ import CodexBarCore import Foundation +/// Shared, lock-guarded ISO8601 formatters for status feeds. Allocating a fresh +/// `ISO8601DateFormatter` per decoded date field is a measurable share of decoding the +/// Google Workspace incidents feed, which can run to hundreds of kilobytes (#1399). +private final class StatusISO8601FormatterBox: @unchecked Sendable { + let lock = NSLock() + let withFractional: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fmt + }() + + let plain: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime] + return fmt + }() +} + +private enum StatusFeedDateParser { + static let box = StatusISO8601FormatterBox() + + static func parse(_ text: String) -> Date? { + self.box.lock.lock() + defer { self.box.lock.unlock() } + return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text) + } + + static func decodingStrategy() -> JSONDecoder.DateDecodingStrategy { + .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + guard let date = StatusFeedDateParser.parse(raw) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") + } + return date + } + } +} + extension UsageStore { - static func fetchStatus( + /// Status feeds decode off the main actor: the Google Workspace incidents payload alone + /// can be hundreds of kilobytes and cost 150-340ms to decode (#1399), and these helpers + /// touch no store state. + @concurrent + nonisolated static func fetchStatus( from baseURL: URL, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ProviderStatus @@ -32,16 +75,7 @@ extension UsageStore { } let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") - } + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let response = try decoder.decode(Response.self, from: data) let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown @@ -51,9 +85,11 @@ extension UsageStore { updatedAt: response.page?.updatedAt) } - static func fetchWorkspaceStatus( + @concurrent + nonisolated static func fetchWorkspaceStatus( productID: String, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + beforeDecoding: (@Sendable () -> Void)? = nil) async throws -> ProviderStatus { guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else { @@ -62,22 +98,14 @@ extension UsageStore { var request = URLRequest(url: url) request.timeoutInterval = 10 let (data, _) = try await transport.data(for: request) + beforeDecoding?() return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } - static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { + nonisolated static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") - } + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let incidents = try decoder.decode([GoogleWorkspaceIncident].self, from: data) let active = incidents.filter { $0.isRelevant(productID: productID) && $0.isActive } @@ -105,7 +133,7 @@ extension UsageStore { return ProviderStatus(indicator: best.indicator, description: description, updatedAt: updatedAt) } - private static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { + private nonisolated static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 case .maintenance: 1 @@ -116,7 +144,7 @@ extension UsageStore { } } - private static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { + private nonisolated static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { switch status?.uppercased() { case "AVAILABLE": return .none case "SERVICE_INFORMATION": return .minor @@ -134,7 +162,7 @@ extension UsageStore { } } - private static func workspaceSummary(from text: String?) -> String? { + private nonisolated static func workspaceSummary(from text: String?) -> String? { guard let text else { return nil } let normalized = text .replacingOccurrences(of: "\r\n", with: "\n") diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index fb39db3cf..6e68219d6 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -96,7 +96,7 @@ extension UsageStore { projection.visibleAccounts.count > 1 } - func refreshCodexVisibleAccountsForMenu() async { + func refreshCodexVisibleAccountsForMenu(generation: UInt64? = nil) async { let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let accounts = self.limitedCodexVisibleAccounts( projection.visibleAccounts, @@ -152,6 +152,7 @@ extension UsageStore { let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh( requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in guard let currentAccount = Self.currentCodexVisibleAccount( matching: snapshot.account, @@ -209,7 +210,8 @@ extension UsageStore { selectedOutcome, account: currentSelectedAccount, snapshot: currentSelectedSnapshot, - sourceLabel: selectedSourceLabel) + sourceLabel: selectedSourceLabel, + generation: generation) } } else { _ = self.prepareCodexAccountScopedRefreshIfNeeded() @@ -407,7 +409,11 @@ extension UsageStore { } } - func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { + func refreshTokenAccounts( + provider: UsageProvider, + accounts: [ProviderTokenAccount], + generation: UInt64? = nil) async + { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first @@ -426,6 +432,7 @@ extension UsageStore { var sawAnyNonCancellationOutcome = false let results = await self.fetchTokenAccountOutcomes(provider: provider, accounts: limitedAccounts) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } for result in results { let account = result.account let outcome = result.outcome @@ -466,9 +473,11 @@ extension UsageStore { selectedOutcome, provider: provider, account: effectiveSelected, - fallbackSnapshot: selectedSnapshot) + fallbackSnapshot: selectedSnapshot, + generation: generation) } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } await self.recordFetchedTokenAccountPlanUtilizationHistory( provider: provider, samples: historySamples, @@ -653,6 +662,9 @@ extension UsageStore { codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) let verbose = self.settings.isVerboseLoggingEnabled + let contextProvider = provider + let originalAccountToken = account?.token + let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, @@ -667,19 +679,26 @@ extension UsageStore { claudeFetcher: self.claudeFetcher, browserDetection: self.browserDetection, selectedTokenAccountID: account?.id, - tokenAccountTokenUpdater: { [weak settings = self.settings] provider, accountID, token in + tokenAccountTokenUpdater: { [weak self] provider, accountID, token in await MainActor.run { - settings?.updateTokenAccount( + guard let self, provider == contextProvider, + self.settings.tokenAccounts(for: provider) + .first(where: { $0.id == accountID })?.token == originalAccountToken + else { + return + } + self.settings.updateTokenAccount( provider: provider, accountID: accountID, token: token) } }, - providerManualTokenUpdater: { [weak settings = self.settings] provider, token in + providerManualTokenUpdater: { [weak self] provider, token in await MainActor.run { - if provider == .stepfun { - settings?.stepfunToken = token - } + guard let self, provider == .stepfun, + self.settings.stepfunToken == originalManualToken + else { return } + self.settings.stepfunToken = token } }, costUsageHistoryDays: self.settings.costUsageHistoryDays) @@ -1162,8 +1181,10 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, snapshot: UsageSnapshot?, - sourceLabel: String?) async + sourceLabel: String?, + generation: UInt64? = nil) async { + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } self.lastFetchAttempts[.codex] = outcome.attempts switch outcome.result { case .success: @@ -1180,6 +1201,7 @@ extension UsageStore { self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) self.seedCodexAccountScopedRefreshGuard(accountEmail: account.email) await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: snapshot) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): guard let message = self.tokenAccountErrorMessage(error) else { @@ -1203,11 +1225,14 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, provider: UsageProvider, account: ProviderTokenAccount?, - fallbackSnapshot: UsageSnapshot?) async + fallbackSnapshot: UsageSnapshot?, + generation: UInt64? = nil) async { await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } self.lastFetchAttempts[provider] = outcome.attempts } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) @@ -1217,6 +1242,9 @@ extension UsageStore { scoped } let backfilled = await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { + return nil as UsageSnapshot? + } let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) @@ -1227,6 +1255,7 @@ extension UsageStore { self.failureGates[provider]?.recordSuccess() return backfilled } + guard let backfilled else { return } await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: backfilled, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 481278397..b6c10084a 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -65,7 +65,7 @@ extension UsageStore { self.startTimer() self.updateProviderRuntimes() await self.refreshHistoricalDatasetIfNeeded() - await self.refresh() + await self.refreshForSettingsChange() } } } @@ -229,6 +229,11 @@ final class UsageStore { @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] + @ObservationIgnored var providerRefreshTasks: [UsageProvider: [ProviderRefreshTaskState]] = [:] + @ObservationIgnored var providerRefreshTaskGeneration: UInt64 = 0 + @ObservationIgnored var providerRefreshWaiterGeneration: UInt64 = 0 + @ObservationIgnored var latestProviderRefreshGenerations: [UsageProvider: UInt64] = [:] + @ObservationIgnored var providerRefreshCounts: [UsageProvider: Int] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? @@ -553,8 +558,8 @@ final class UsageStore { func runRefresh( forceTokenUsage: Bool = false, - startupConnectivityRetryAttempt: Int?) - async + startupConnectivityRetryAttempt: Int?, + coalesceProviderRefreshesOverride: Bool? = nil) async { guard !self.isRefreshing else { return } self.prepareRefreshState() @@ -587,7 +592,12 @@ final class UsageStore { await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { - group.addTask { await self.refreshProvider(provider) } + group.addTask { + await self.refreshProvider( + provider, + coalesceIfRefreshing: coalesceProviderRefreshesOverride ?? + (ProviderInteractionContext.current == .background)) + } if availableRefreshProviders.contains(provider) { group.addTask { await self.refreshStatus(provider) } } @@ -1161,7 +1171,7 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, + case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .deepgram: @@ -1285,70 +1295,6 @@ extension UsageStore { #endif } - private struct APIKeyDebugContext { - let label: String - let resolution: ProviderTokenResolution? - let configToken: String? - let hasEnvToken: Bool - let hasTokenAccount: Bool - } - - private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openai) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openai, - config: config) - return APIKeyDebugContext( - label: "OPENAI_API_KEY", - resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .azureopenai) - let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( - base: processEnvironment, - provider: .azureopenai, - config: config) - return APIKeyDebugContext( - label: "AZURE_OPENAI_API_KEY", - resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openrouter) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openrouter, - config: config) - return APIKeyDebugContext( - label: "OPENROUTER_API_KEY", - resolution: ProviderTokenResolver.openRouterResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .elevenlabs) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .elevenlabs, - config: config) - return APIKeyDebugContext( - label: "ELEVENLABS_API_KEY", - resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { self.apiKeyDebugLine( label: context.label, diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 3ac92fc59..6f9aa532c 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -1,6 +1,78 @@ import CodexBarCore import Foundation +final class ProviderRefreshTaskState: @unchecked Sendable { + let generation: UInt64 + + private let lock = NSLock() + private var task: Task? + private var waiterIDs: Set = [] + private var completed = false + private var retryRequired = false + + init(generation: UInt64) { + self.generation = generation + } + + func install(task: Task) { + self.lock.withLock { + self.task = task + } + } + + func addWaiter(_ waiterID: UInt64) -> Task? { + self.lock.withLock { + self.waiterIDs.insert(waiterID) + return self.task + } + } + + func cancelWaiter(_ waiterID: UInt64) { + let taskToCancel = self.lock.withLock { + guard self.waiterIDs.remove(waiterID) != nil else { return nil as Task? } + return self.waiterIDs.isEmpty && !self.completed ? self.task : nil + } + taskToCancel?.cancel() + } + + func finishWaiter(_ waiterID: UInt64) { + _ = self.lock.withLock { + self.waiterIDs.remove(waiterID) + } + } + + func markCompleted(retryRequired: Bool) { + self.lock.withLock { + self.completed = true + self.retryRequired = retryRequired + } + } + + func cancelTask() { + let task = self.lock.withLock { + self.completed ? nil : self.task + } + task?.cancel() + } + + func waitForTaskCompletion() async { + let task = self.lock.withLock { self.task } + await task?.value + } + + var isCompleted: Bool { + self.lock.withLock { self.completed } + } + + var shouldRetry: Bool { + self.lock.withLock { self.retryRequired } + } + + var canRemove: Bool { + self.lock.withLock { self.completed && self.waiterIDs.isEmpty } + } +} + enum ProviderStatusIndicator: String { case none case minor diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index dd692011d..c1a1758e5 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -33,6 +33,7 @@ public enum CodexBarConfigValidator { .openai, .opencode, .opencodego, + .devin, .deepgram, ] diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index dc8dd1db6..d9597f196 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -22,6 +22,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool + public let unlimited: Bool private let entitlementWasDecoded: Bool private let remainingWasDecoded: Bool public var usedPercent: Double { @@ -33,6 +34,10 @@ public struct CopilotUsageResponse: Sendable, Decodable { } public var isPlaceholder: Bool { + if self.unlimited { + return false + } + if self.entitlement == 0, self.remaining == 0, self.percentRemaining == 0, @@ -55,6 +60,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { case remaining case percentRemaining = "percent_remaining" case quotaId = "quota_id" + case unlimited } public init( @@ -62,13 +68,15 @@ public struct CopilotUsageResponse: Sendable, Decodable { remaining: Double, percentRemaining: Double, quotaId: String, - hasPercentRemaining: Bool = true) + hasPercentRemaining: Bool = true, + unlimited: Bool = false) { self.entitlement = entitlement self.remaining = remaining - self.percentRemaining = percentRemaining + self.percentRemaining = unlimited ? 100 : percentRemaining self.quotaId = quotaId - self.hasPercentRemaining = hasPercentRemaining + self.hasPercentRemaining = unlimited || hasPercentRemaining + self.unlimited = unlimited self.entitlementWasDecoded = true self.remainingWasDecoded = true } @@ -81,8 +89,12 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.remaining = decodedRemaining ?? 0 self.entitlementWasDecoded = decodedEntitlement != nil self.remainingWasDecoded = decodedRemaining != nil + let decodedUnlimited = try container.decodeIfPresent(Bool.self, forKey: .unlimited) ?? false let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) - if let decodedPercent { + if decodedUnlimited { + self.percentRemaining = 100 + self.hasPercentRemaining = true + } else if let decodedPercent { self.percentRemaining = decodedPercent self.hasPercentRemaining = true } else if let entitlement = decodedEntitlement, @@ -98,6 +110,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.hasPercentRemaining = false } self.quotaId = try container.decodeIfPresent(String.self, forKey: .quotaId) ?? "" + self.unlimited = decodedUnlimited } private static func decodeNumberIfPresent( diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 0dd1a79dc..a8019ea11 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -146,53 +146,60 @@ public struct CostUsageFetcher: Sendable { if forceRefresh { options.refreshMinIntervalSeconds = 0 } - let checkCancellation: CostUsageScanner.CancellationCheck = { - try Task.checkCancellation() + var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() + if resolvedPiOptions.cacheRoot == nil { + resolvedPiOptions.cacheRoot = options.cacheRoot } - try Task.checkCancellation() - var daily = try CostUsageScanner.loadDailyReportCancellable( - provider: provider, - since: since, - until: until, - now: now, - options: options, - checkCancellation: checkCancellation) - try Task.checkCancellation() + if forceRefresh { + resolvedPiOptions.refreshMinIntervalSeconds = 0 + } + let piOptions = resolvedPiOptions - if provider == .vertexai, - !allowVertexClaudeFallback, - options.claudeLogProviderFilter == .vertexAIOnly, - daily.data.isEmpty - { - var fallback = options - fallback.claudeLogProviderFilter = .all - daily = try CostUsageScanner.loadDailyReportCancellable( + try Task.checkCancellation() + // The corpus scans below are synchronous and can run for minutes on large session + // archives. They execute on the dedicated scan queue so they never occupy a cooperative + // pool thread; CostUsageScanExecutor bridges this task's cancellation into the + // scanner-level checks. + let scanOptions = options + let daily = try await CostUsageScanExecutor.run { checkCancellation in + var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, until: until, now: now, - options: fallback, + options: scanOptions, checkCancellation: checkCancellation) - try Task.checkCancellation() - } + try checkCancellation() - if provider == .codex || provider == .claude { - var piOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() - if piOptions.cacheRoot == nil { - piOptions.cacheRoot = options.cacheRoot + if provider == .vertexai, + !allowVertexClaudeFallback, + scanOptions.claudeLogProviderFilter == .vertexAIOnly, + daily.data.isEmpty + { + var fallback = scanOptions + fallback.claudeLogProviderFilter = .all + daily = try CostUsageScanner.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: fallback, + checkCancellation: checkCancellation) + try checkCancellation() } - if forceRefresh { - piOptions.refreshMinIntervalSeconds = 0 + + if provider == .codex || provider == .claude { + let piReport = try PiSessionCostScanner.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: piOptions, + checkCancellation: checkCancellation) + try checkCancellation() + daily = CostUsageDailyReport.merged([daily, piReport]) } - let piReport = try PiSessionCostScanner.loadDailyReportCancellable( - provider: provider, - since: since, - until: until, - now: now, - options: piOptions, - checkCancellation: checkCancellation) - try Task.checkCancellation() - daily = CostUsageDailyReport.merged([daily, piReport]) + return daily } return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) @@ -210,7 +217,9 @@ public struct CostUsageFetcher: Sendable { return nil } - return await Task.detached(priority: .utility) { + // Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the + // cooperative pool alongside the scans themselves. + let cachedSnapshot: CostUsageTokenSnapshot?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now @@ -247,7 +256,8 @@ public struct CostUsageFetcher: Sendable { from: CostUsageDailyReport.merged(reports), now: now, historyDays: clampedHistoryDays) - }.value + } + return cachedSnapshot.flatMap(\.self) } private static func loadBedrockDailyReport( diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift new file mode 100644 index 000000000..aef8b2b78 --- /dev/null +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Cost-usage scans read and parse the full local session corpus synchronously and can run for +/// minutes on large archives. Executing that work inline on Swift's cooperative thread pool +/// starves every other async task in the process — menus freeze while the main thread sits idle — +/// and overlapping provider scans multiply both the pool pressure and the disk load. This +/// executor pins all corpus scans to a single serial utility queue off the cooperative pool, so +/// long scans cost one dedicated thread instead of the app's async runtime. +public enum CostUsageScanExecutor { + public static let queueLabel = "com.steipete.codexbar.cost-usage-scan" + + private static let queue = DispatchQueue(label: queueLabel, qos: .utility) + + private final class RunState: @unchecked Sendable { + private enum Phase { + case initial + case queued + case running + case completed + } + + private let lock = NSLock() + private var phase: Phase = .initial + private var cancellationRequested = false + private var continuation: CheckedContinuation? + + func install(_ continuation: CheckedContinuation) -> Bool { + let shouldEnqueue: Bool + let shouldResumeCancellation: Bool + self.lock.lock() + if self.cancellationRequested { + self.phase = .completed + shouldEnqueue = false + shouldResumeCancellation = true + } else { + self.phase = .queued + self.continuation = continuation + shouldEnqueue = true + shouldResumeCancellation = false + } + self.lock.unlock() + + if shouldResumeCancellation { + continuation.resume(throwing: CancellationError()) + } + return shouldEnqueue + } + + func begin() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard self.phase == .queued else { return false } + self.phase = .running + return true + } + + func cancel() { + let continuation: CheckedContinuation? + self.lock.lock() + self.cancellationRequested = true + if self.phase == .queued { + self.phase = .completed + continuation = self.continuation + self.continuation = nil + } else { + continuation = nil + } + self.lock.unlock() + continuation?.resume(throwing: CancellationError()) + } + + func checkCancellation() throws { + self.lock.lock() + let cancellationRequested = self.cancellationRequested + self.lock.unlock() + if cancellationRequested { + throw CancellationError() + } + } + + func complete(with result: Result) { + let continuation: CheckedContinuation? + let resolvedResult: Result + self.lock.lock() + guard self.phase == .running else { + self.lock.unlock() + return + } + self.phase = .completed + continuation = self.continuation + self.continuation = nil + resolvedResult = self.cancellationRequested ? .failure(CancellationError()) : result + self.lock.unlock() + continuation?.resume(with: resolvedResult) + } + } + + /// Runs `work` on the serial scan queue and bridges Swift task cancellation into the + /// scanner's cooperative `checkCancellation` callbacks. Work that is still queued when the + /// awaiting task is cancelled resumes immediately with `CancellationError` instead of + /// waiting behind an in-flight scan. + public static func run( + _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) + async throws -> T + { + try await self.run(on: self.queue, work) + } + + static func run( + on queue: DispatchQueue, + _ work: @escaping @Sendable (_ checkCancellation: @escaping @Sendable () throws -> Void) throws -> T) + async throws -> T + { + let state = RunState() + let checkCancellation: @Sendable () throws -> Void = { + try state.checkCancellation() + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard state.install(continuation) else { return } + queue.async { + guard state.begin() else { return } + state.complete(with: Result { try work(checkCancellation) }) + } + } + } onCancel: { + state.cancel() + } + } +} diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a0d497672..3b2fddb7a 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "dd86017647affbc8" + static let value = "61473beda7b915f0" } diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 86451a75d..0e44c5f0f 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -7,6 +7,8 @@ public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? + private static let processForceDisabledLock = NSLock() + private nonisolated(unsafe) static var processForceDisabledReason: String? public nonisolated(unsafe) static var isDisabled: Bool { get { @@ -16,6 +18,7 @@ public enum KeychainAccessGate { return true } #endif + if self.processDisableReason != nil { return true } if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { @@ -31,6 +34,21 @@ public enum KeychainAccessGate { } } + public static func forceDisabledForProcess(reason: String) { + self.processForceDisabledLock.lock() + self.processForceDisabledReason = reason + self.processForceDisabledLock.unlock() + #if os(macOS) && canImport(SweetCookieKit) + BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled + #endif + } + + public static var processDisableReason: String? { + self.processForceDisabledLock.lock() + defer { self.processForceDisabledLock.unlock() } + return self.processForceDisabledReason + } + #if DEBUG private nonisolated(unsafe) static var forcesDisabledUnderTests: Bool { self.isRunningUnderTests @@ -70,6 +88,9 @@ public enum KeychainAccessGate { #if DEBUG static func resetOverrideForTesting() { self.overrideValue = nil + self.processForceDisabledLock.lock() + self.processForceDisabledReason = nil + self.processForceDisabledLock.unlock() #if os(macOS) && canImport(SweetCookieKit) BrowserCookieKeychainAccessGate.isDisabled = self.isDisabled #endif diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 7656ed5f3..76ff53faf 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -26,6 +26,7 @@ public enum LogCategories { public static let deepSeekSettings = "deepseek-settings" public static let deepSeekUsage = "deepseek-usage" public static let deepgramUsage = "deepgram-usage" + public static let devin = "devin" public static let doubaoUsage = "doubao-usage" public static let elevenLabsUsage = "elevenlabs-usage" public static let geminiProbe = "gemini-probe" diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index bf36e53cf..5629bf4e7 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -538,7 +538,7 @@ public struct OpenAIDashboardBrowserCookieImporter { request.setValue("application/json", forHTTPHeaderField: "Accept") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("API \(url.host ?? "chatgpt.com") \(url.path) status=\(status)") guard status >= 200, status < 300 else { continue } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 48703a8c4..6dfb2eefd 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -763,7 +763,7 @@ public struct OpenAIDashboardFetcher { guard !cookieHeader.isEmpty else { return nil } do { - let (data, response) = try await URLSession.shared.data( + let (data, response) = try await ProviderHTTPClient.shared.data( for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader)) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("usage api status=\(status)") @@ -793,7 +793,7 @@ public struct OpenAIDashboardFetcher { for url in endpoints { do { - let (data, response) = try await URLSession.shared.data( + let (data, response) = try await ProviderHTTPClient.shared.data( for: self.dashboardIdentityAPIRequest(url: url, cookieHeader: cookieHeader)) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("identity api \(url.path) status=\(status)") diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 116731b38..8de1cf5bf 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -110,7 +110,12 @@ final class OpenAIDashboardWebViewCache { private var entries: [ObjectIdentifier: Entry] = [:] /// Keep the WebView alive only long enough for immediate retries/menu reopens. /// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups. - private let idleTimeout: TimeInterval = 60 + private let idleTimeout: TimeInterval + private var idlePruneWorkItem: DispatchWorkItem? + private var idlePruneGeneration = 0 + #if DEBUG + private(set) var idlePruneDeadlineForTesting: Date? + #endif /// Reuse the validated analytics page only for the immediate next handoff. private let preservedPageHandoffTimeout: TimeInterval = 5 private let blankURL = URL(string: "about:blank")! @@ -153,26 +158,34 @@ final class OpenAIDashboardWebViewCache { })(); """ + init(idleTimeout: TimeInterval = 60) { + self.idleTimeout = idleTimeout + } + private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false - entry.lastUsedAt = Date() + let now = Date() + entry.lastUsedAt = now self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) self.prepareCachedWebViewForIdle( entry.webView, host: entry.host, preserveLoadedPage: preserveLoadedPage) - self.prune(now: Date()) + self.prune(now: now) + self.scheduleNextIdlePrune(now: now) } private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) { entry.isBusy = false - entry.lastUsedAt = Date() + let now = Date() + entry.lastUsedAt = now self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage) self.prepareCachedWebViewForIdle( webView, host: entry.host, preserveLoadedPage: preserveLoadedPage) - self.prune(now: Date()) + self.prune(now: now) + self.scheduleNextIdlePrune(now: now) } // MARK: - Testing support @@ -248,6 +261,7 @@ final class OpenAIDashboardWebViewCache { /// Clear all cached entries (for test isolation). func clearAllForTesting() { + self.cancelIdlePrune() for (_, entry) in self.entries { entry.clearPreservedPage() entry.host.close() @@ -430,9 +444,11 @@ final class OpenAIDashboardWebViewCache { entry.clearPreservedPage() Self.log.debug("OpenAI webview evicted") entry.host.close() + self.scheduleNextIdlePrune() } func evictAll() { + self.cancelIdlePrune() let existing = self.entries self.entries.removeAll() for (_, entry) in existing { @@ -464,6 +480,46 @@ final class OpenAIDashboardWebViewCache { host.hide() } + /// Schedule against the oldest idle entry so later releases cannot postpone its eviction. + private func scheduleNextIdlePrune(now: Date = Date()) { + self.cancelIdlePrune() + + guard let nextExpiry = self.entries.values + .filter({ !$0.isBusy }) + .map({ $0.lastUsedAt.addingTimeInterval(self.idleTimeout) }) + .min() + else { return } + + let generation = self.idlePruneGeneration + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + guard let self, self.idlePruneGeneration == generation else { return } + self.idlePruneWorkItem = nil + #if DEBUG + self.idlePruneDeadlineForTesting = nil + #endif + let pruneTime = Date() + self.prune(now: pruneTime) + self.scheduleNextIdlePrune(now: pruneTime) + } + } + self.idlePruneWorkItem = workItem + #if DEBUG + self.idlePruneDeadlineForTesting = nextExpiry + #endif + let delay = max(0, nextExpiry.timeIntervalSince(now)) + 0.01 + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func cancelIdlePrune() { + self.idlePruneGeneration &+= 1 + self.idlePruneWorkItem?.cancel() + self.idlePruneWorkItem = nil + #if DEBUG + self.idlePruneDeadlineForTesting = nil + #endif + } + private func prune(now: Date) { for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) { entry.clearPreservedPage() @@ -475,7 +531,7 @@ final class OpenAIDashboardWebViewCache { } let expired = self.entries.filter { _, entry in - !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout + !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) >= self.idleTimeout } for (key, entry) in expired { entry.host.close() diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index 6741c5921..aebac39fe 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -1,14 +1,14 @@ import Foundation enum PiSessionCostCacheIO { - /// Artifact version 2 is shared by both upstream's stale-cache rebuild fix - /// (78e186d4) and our pricing-fingerprint invalidation (fork 0.23.1): - /// pi-session cache stores per-(day, provider, model) packed usage with - /// `costNanos` baked in at parse time. Pre-0.23 entries used pricing - /// without `gpt-5.5` and without the fallback resolver, so cached - /// `costNanos` are stale. Bumping the file version sidesteps the - /// migration entirely (old cache file ignored, fresh scan at next launch). - private static let artifactVersion = 2 + /// Pi-session cache stores per-(day, provider, model) packed usage with + /// `costNanos` baked in at parse time, so cached costs go stale whenever + /// pricing changes. Version 3 matches upstream's Claude pricing + /// correction (20004f3d); bumping the file version sidesteps migration + /// entirely (old cache file ignored, fresh scan at next launch). The + /// fork's `pricingFingerprint` stamp additionally invalidates the cache + /// when the pricing table changes without a version bump. + private static let artifactVersion = 3 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -53,7 +53,11 @@ enum PiSessionCostCacheIO { let data = (try? JSONEncoder().encode(stamped)) ?? Data() do { try data.write(to: tmp, options: [.atomic]) - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } } catch { try? FileManager.default.removeItem(at: tmp) } @@ -72,7 +76,7 @@ struct PiSessionCostCache: Codable { var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 2, pricingFingerprint: String? = nil) { + init(version: Int = 3, pricingFingerprint: String? = nil) { self.version = version self.pricingFingerprint = pricingFingerprint } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index a1aebbdab..7e1adb397 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -384,6 +384,7 @@ enum PiSessionCostScanner { provider: identity.provider, modelName: identity.modelName, message: message, + pricingDate: date, pricingContext: pricingContext) add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage) } @@ -524,6 +525,7 @@ enum PiSessionCostScanner { provider: UsageProvider, modelName: String, message: [String: Any], + pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> PiPackedUsage { let usage = (message["usage"] as? [String: Any]) ?? [:] @@ -571,10 +573,12 @@ enum PiSessionCostScanner { cacheWriteTokens: cacheWrite, outputTokens: output, totalTokens: totalTokens) + // Pi JSONL does not record Anthropic cache retention, so use Pi's persisted default tariff. let costUSD = self.computedCostUSD( provider: provider, modelName: modelName, usage: rawUsage, + pricingDate: pricingDate, pricingContext: pricingContext) let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? 0 @@ -593,6 +597,7 @@ enum PiSessionCostScanner { provider: UsageProvider, modelName: String, usage: PiPackedUsage, + pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> Double? { switch provider { @@ -611,6 +616,7 @@ enum PiSessionCostScanner { cacheReadInputTokens: usage.cacheReadTokens, cacheCreationInputTokens: usage.cacheWriteTokens, outputTokens: usage.outputTokens, + pricingDate: pricingDate, modelsDevCatalog: pricingContext?.catalog, modelsDevCacheRoot: pricingContext?.cacheRoot) default: @@ -634,7 +640,9 @@ enum PiSessionCostScanner { } return 0 } +} +extension PiSessionCostScanner { private static func mappedProvider(fromPiProvider provider: String) -> UsageProvider? { switch provider.lowercased() { case "openai-codex": diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index f5ea38a4c..68b8b383e 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -171,7 +171,7 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl private let session: URLSession public init(session: URLSession? = nil) { - self.session = session ?? URLSession(configuration: Self.defaultConfiguration()) + self.session = session ?? Self.redirectGuardedSession() } static func defaultConfiguration() -> URLSessionConfiguration { @@ -189,7 +189,16 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl // XCTest URLProtocol.registerClass stubs only intercept URLSession.shared on macOS. return .shared } - return URLSession(configuration: self.defaultConfiguration()) + return self.redirectGuardedSession() + } + + static func redirectGuardedSession( + configuration: URLSessionConfiguration = ProviderHTTPClient.defaultConfiguration()) -> URLSession + { + URLSession( + configuration: configuration, + delegate: ProviderHTTPRedirectGuardDelegate(), + delegateQueue: nil) } private static var isRunningTests: Bool { @@ -207,3 +216,38 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl try await self.session.data(for: request) } } + +final class ProviderHTTPRedirectGuardDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void) + { + completionHandler(Self.guardedRedirectRequest(originalURL: task.originalRequest?.url, redirectRequest: request)) + } + + static func guardedRedirectRequest(originalURL: URL?, redirectRequest request: URLRequest) -> URLRequest? { + guard let originalURL, let redirectedURL = request.url else { return nil } + guard originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } + guard redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } + guard self.isSameOrigin(originalURL, redirectedURL) else { return nil } + return request + } + + private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() + && lhs.host?.lowercased() == rhs.host?.lowercased() + && self.normalizedPort(lhs) == self.normalizedPort(rhs) + } + + private static func normalizedPort(_ url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 79c8dd2b8..99cf42c3e 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -87,12 +87,13 @@ public struct AntigravityStatusSnapshot: Sendable { } let normalized = Self.normalizedModels(self.modelQuotas) - let summaryModels: [AntigravityNormalizedModel] = switch self.source { + let summaryCandidates: [AntigravityNormalizedModel] = switch self.source { case .local: normalized case .remote: normalized.filter(Self.isRemoteSummaryCandidate) } + let summaryModels = summaryCandidates.filter { $0.quota.remainingFraction != nil } let primaryQuota = Self.representative(for: .claude, in: summaryModels) let secondaryQuota = Self.representative(for: .geminiPro, in: summaryModels) let tertiaryQuota = Self.representative(for: .geminiFlash, in: summaryModels) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index 25dcae7ac..3b2f8a075 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -119,7 +119,7 @@ public struct CodexAccountReconciliationSnapshot: Equatable, Sendable { } } -public struct DefaultCodexAccountReconciler { +public struct DefaultCodexAccountReconciler: Sendable { public let storeLoader: @Sendable () throws -> ManagedCodexAccountSet public let systemObserver: any CodexSystemAccountObserving public let activeSource: CodexActiveSource diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index a2da97221..7a3a41665 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -421,17 +421,18 @@ public struct CursorStatusSnapshot: Sendable { /// Convert to UsageSnapshot for the common provider interface public func toUsageSnapshot() -> UsageSnapshot { - // Primary: For legacy request-based plans, use request usage; otherwise use plan percentage - let primaryUsedPercent: Double = if self.isLegacyRequestPlan, - let used = self.requestsUsed, - let limit = self.requestsLimit, - limit > 0 + let cursorRequests: CursorRequestUsage? = if let used = self.requestsUsed, + let limit = self.requestsLimit, + limit > 0 { - (Double(used) / Double(limit)) * 100 + CursorRequestUsage(used: used, limit: limit) } else { - self.planPercentUsed + nil } + // Primary: For usable legacy request quotas, use request usage; otherwise preserve plan percentage. + let primaryUsedPercent = cursorRequests?.usedPercent ?? self.planPercentUsed + let billingCycleWindowMinutes = Self.billingCycleWindowMinutes( start: self.billingCycleStart, end: self.billingCycleEnd) @@ -442,8 +443,10 @@ public struct CursorStatusSnapshot: Sendable { resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) - // Secondary: Auto + Composer usage (shown as its own bar below Total) - let secondary: RateWindow? = self.autoPercentUsed.map { pct in + // Secondary: Auto + Composer usage (shown as its own bar below Total). + // Legacy request-based plans don't have the token-based Auto/API breakdown — those percentages + // come from the new usage-based pricing and are meaningless next to a request quota, so hide them. + let secondary: RateWindow? = cursorRequests != nil ? nil : self.autoPercentUsed.map { pct in RateWindow( usedPercent: pct, windowMinutes: billingCycleWindowMinutes, @@ -451,8 +454,8 @@ public struct CursorStatusSnapshot: Sendable { resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) } - // Tertiary: API (named model) usage - let tertiary: RateWindow? = self.apiPercentUsed.map { pct in + // Tertiary: API (named model) usage — hidden for legacy request-based plans (see above). + let tertiary: RateWindow? = cursorRequests != nil ? nil : self.apiPercentUsed.map { pct in RateWindow( usedPercent: pct, windowMinutes: billingCycleWindowMinutes, @@ -479,15 +482,6 @@ public struct CursorStatusSnapshot: Sendable { nil } - // Legacy plan request usage (when maxRequestUsage is set) - let cursorRequests: CursorRequestUsage? = if let used = self.requestsUsed, - let limit = self.requestsLimit - { - CursorRequestUsage(used: used, limit: limit) - } else { - nil - } - let identity = ProviderIdentitySnapshot( providerID: .cursor, accountEmail: self.accountEmail, @@ -542,6 +536,9 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { case parseFailed(String) case noSessionCookie + static let safariFullDiskAccessHint = + "If you use Safari, grant QuotaKit Full Disk Access in System Settings ▸ Privacy & Security." + public var errorDescription: String? { switch self { case .notLoggedIn: @@ -551,8 +548,8 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { case let .parseFailed(msg): "Could not parse Cursor usage: \(msg)" case .noSessionCookie: - "No Cursor session found. Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " - + "If you use Safari, grant QuotaKit Full Disk Access in System Settings ▸ Privacy & Security. " + "No Cursor session found. \(Self.safariFullDiskAccessHint) " + + "Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " + "You can also sign in to Cursor from the QuotaKit menu (Add / switch account)." } } diff --git a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift new file mode 100644 index 000000000..121d22ba6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift @@ -0,0 +1,93 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DevinProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .devin, + metadata: ProviderMetadata( + id: .devin, + displayName: "Devin", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Devin usage", + cliName: "devin", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.devinCookieImportOrder, + dashboardURL: "https://app.devin.ai", + subscriptionDashboardURL: "https://app.devin.ai/settings/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .devin, + iconResourceName: "ProviderIcon-devin", + color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Devin cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DevinWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "devin", + versionDetector: nil)) + } +} + +struct DevinWebFetchStrategy: ProviderFetchStrategy { + let id: String = "devin.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let settings = context.settings?.devin + let source = settings?.cookieSource ?? .auto + guard source != .off else { return false } + if source == .manual { + return DevinUsageFetcher.manualAuth(from: Self.bearerTokenOverride(context: context)) != nil + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = DevinUsageFetcher(browserDetection: context.browserDetection) + let settings = context.settings?.devin + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.devin).verbose(msg) } + : nil + let snapshot = try await fetcher.fetch( + bearerTokenOverride: settings?.cookieSource == .manual ? Self.bearerTokenOverride(context: context) : nil, + organizationOverride: Self.organizationOverride(context: context), + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func bearerTokenOverride(context: ProviderFetchContext) -> String? { + context.env["DEVIN_BEARER_TOKEN"] + ?? context.env["DEVIN_AUTHORIZATION"] + ?? context.settings?.devin?.manualBearerToken + } + + private static func organizationOverride(context: ProviderFetchContext) -> String? { + context.env["DEVIN_ORGANIZATION"] + ?? context.env["DEVIN_ORG"] + ?? context.settings?.devin?.organization + } +} diff --git a/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift new file mode 100644 index 000000000..102ed6575 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinSessionImporter.swift @@ -0,0 +1,459 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum DevinSessionImporter { + nonisolated(unsafe) static var importSessionOverrideForTesting: + ((BrowserDetection, String?, ((String) -> Void)?) -> SessionInfo?)? + + private static let storageOrigin = "https://app.devin.ai" + private static let externalOrgPrefix = "last-internal-org-for-external-org-v1-" + + struct SessionInfo: Equatable { + let accessToken: String + let organization: String? + let internalOrganizationID: String? + let sourceLabel: String + } + + struct LocalStorageCandidate { + let label: String + let url: URL + } + + static func importSession( + browserDetection: BrowserDetection, + organizationOverride: String? = nil, + logger: ((String) -> Void)? = nil) -> SessionInfo? + { + if let override = self.importSessionOverrideForTesting { + return override(browserDetection, organizationOverride, logger) + } + + let sessions = self.importSessions( + browserDetection: browserDetection, + organizationOverride: organizationOverride, + logger: logger) + return sessions.first + } + + static func importSessions( + browserDetection: BrowserDetection, + organizationOverride: String? = nil, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importSessionOverrideForTesting { + return override(browserDetection, organizationOverride, logger).map { [$0] } ?? [] + } + + let log: (String) -> Void = { msg in logger?("[devin-storage] \(msg)") } + let candidates = self.chromeLocalStorageCandidates(browserDetection: browserDetection) + if !candidates.isEmpty { + log("Chrome local storage candidates: \(candidates.count)") + } + + var sessions: [SessionInfo] = [] + for candidate in candidates { + let storage = self.readLocalStorage(from: candidate.url, logger: log) + guard let session = self.session( + from: storage, + organizationOverride: organizationOverride, + sourceLabel: candidate.label) + else { + continue + } + log( + "Found Devin session in \(candidate.label); " + + "organization=\(session.organization != nil), internalOrganizationID=" + + "\(session.internalOrganizationID != nil)") + sessions.append(session) + } + sessions = self.rankSessions(self.deduplicateSessions(sessions)) + + if sessions.isEmpty { + log("No Devin session found in browser local storage") + } + return sessions + } + + static func session( + from storage: [String: String], + organizationOverride: String? = nil, + sourceLabel: String) -> SessionInfo? + { + guard let accessToken = self.accessToken(from: storage) else { + return nil + } + let organizationInfo = self.organizationInfo(from: storage, organizationOverride: organizationOverride) + return SessionInfo( + accessToken: accessToken, + organization: organizationInfo.organization, + internalOrganizationID: organizationInfo.internalOrganizationID, + sourceLabel: sourceLabel) + } + + static func accessToken(from storage: [String: String]) -> String? { + for (key, value) in storage where self.isAuth1StorageKey(key) { + guard let json = self.jsonObject(from: value), + let token = self.findAuth1Token(in: json) + else { + continue + } + return token + } + + for (key, value) in storage where self.isAuth0StorageKey(key) { + guard let json = self.jsonObject(from: value), + let token = self.findAccessToken(in: json) + else { + continue + } + return token + } + + for value in storage.values { + guard let json = self.jsonObject(from: value), + let token = self.findAccessToken(in: json) + else { + continue + } + return token + } + + return nil + } + + static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + var order: [String] = [] + var bestByToken: [String: SessionInfo] = [:] + for session in sessions { + if let existing = bestByToken[session.accessToken] { + if self.organizationScore(session) > self.organizationScore(existing) { + bestByToken[session.accessToken] = session + } + } else { + order.append(session.accessToken) + bestByToken[session.accessToken] = session + } + } + return order.compactMap { bestByToken[$0] } + } + + static func rankSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + sessions.enumerated() + .sorted { lhs, rhs in + let lhsScore = self.organizationScore(lhs.element) + let rhsScore = self.organizationScore(rhs.element) + return lhsScore == rhsScore ? lhs.offset < rhs.offset : lhsScore > rhsScore + } + .map(\.element) + } + + private static func organizationScore(_ session: SessionInfo) -> Int { + (session.organization == nil ? 0 : 1) + (session.internalOrganizationID == nil ? 0 : 2) + } + + static func organizationInfo( + from storage: [String: String], + organizationOverride: String?) -> (organization: String?, internalOrganizationID: String?) + { + let override = DevinUsageFetcher.normalizedOrganization(organizationOverride) + let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:)) + var firstInternalOrgID: String? + + for (key, value) in storage where self.isExternalOrgStorageKey(key) { + let suffix = self.externalOrgSlug(from: key) + let orgID = self.cleanedOrgID(value) + if firstInternalOrgID == nil { + firstInternalOrgID = orgID + } + if let overrideSlug, suffix == overrideSlug { + return (override, orgID) + } + if override == nil, suffix != "null" { + return ("org/\(suffix)", orgID) + } + } + + if let inferred = self.inferredOrganizationInfo(from: storage, override: override) { + return inferred + } + + if let override { + return (override, firstInternalOrgID ?? self.orgID(fromNormalizedOrganization: override)) + } + + return (firstInternalOrgID.map { "organizations/\($0)" }, firstInternalOrgID) + } + + static func decodedStorageValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(String.self, from: data) + { + return decoded.trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func chromeLocalStorageCandidates(browserDetection: BrowserDetection) -> [LocalStorageCandidate] { + let installedBrowsers = self.localStorageBrowsers(browserDetection: browserDetection) + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [LocalStorageCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileLocalStorageDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + static func localStorageBrowsers(browserDetection: BrowserDetection) -> [Browser] { + let order = ProviderDefaults.metadata[.devin]?.browserCookieOrder ?? [.chrome] + return order.browsersWithProfileData(using: browserDetection) + } + + private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + return entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .compactMap { dir in + let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil } + return LocalStorageCandidate(label: "\(labelPrefix) \(dir.lastPathComponent)", url: levelDBURL) + } + } + + private static func readLocalStorage(from levelDBURL: URL, logger: ((String) -> Void)?) -> [String: String] { + var storage: [String: String] = [:] + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: self.storageOrigin, + in: levelDBURL, + logger: logger) + for entry in entries { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + for entry in textEntries where storage[entry.key] == nil { + if self.isUsefulStorageKey(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + } + + return storage + } + + private static func jsonObject(from raw: String) -> Any? { + guard let data = raw.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + + private static func findAuth1Token(in object: Any) -> String? { + guard let dictionary = object as? [String: Any], + let token = dictionary["token"] as? String + else { + return nil + } + let value = token.trimmingCharacters(in: .whitespacesAndNewlines) + return value.hasPrefix("auth1_") && value.count > 20 ? value : nil + } + + private static func findAccessToken(in object: Any) -> String? { + if let dictionary = object as? [String: Any] { + for key in ["access_token", "accessToken"] { + if let value = dictionary[key] as? String, + self.looksLikeToken(value) + { + return value + } + } + for value in dictionary.values { + if let found = self.findAccessToken(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findAccessToken(in: value) { + return found + } + } + } + + return nil + } + + private static func looksLikeToken(_ raw: String) -> Bool { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return value.count > 20 && (value.hasPrefix("eyJ") || value.contains(".")) + } + + private static func isAuth1StorageKey(_ key: String) -> Bool { + key.hasSuffix("auth1_session") + } + + private static func isAuth0StorageKey(_ key: String) -> Bool { + key.contains("auth0spajs@@::") + } + + private static func isExternalOrgStorageKey(_ key: String) -> Bool { + key.contains(self.externalOrgPrefix) + } + + private static func isUsefulStorageKey(_ key: String) -> Bool { + self.isAuth1StorageKey(key) || + self.isAuth0StorageKey(key) || + self.isExternalOrgStorageKey(key) || + key.contains("post-auth-v") || + key.contains("member-info-v") || + key.contains("feature-flags-cache:org-") || + key.contains("feature-flags-cache:org_") + } + + private static func inferredOrganizationInfo( + from storage: [String: String], + override: String?) -> (organization: String?, internalOrganizationID: String?)? + { + let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:)) + let overrideOrgID = override.flatMap(self.orgID(fromNormalizedOrganization:)) + var fallbackSlug: String? + var fallbackInternalOrgID: String? + + for (key, value) in storage { + let object = self.jsonObject(from: value) + let internalOrgID = self.cleanedOrgID(self.firstString( + in: object, + matching: ["internalOrgId", "internal_org_id", "org_id", "orgId"])) + ?? self.internalOrgIDFromStorageKey(key) + let slug = self.cleanedSlug( + self.slugFromPostAuthKey(key) ?? + self.firstString(in: object, matching: ["orgName", "org_name", "externalOrgId", "external_org_id"])) + + if let overrideOrgID, internalOrgID == overrideOrgID { + return (override, internalOrgID) + } + if let overrideSlug, slug == overrideSlug { + return (override, internalOrgID) + } + + if fallbackSlug == nil, let slug { + fallbackSlug = slug + } + if fallbackInternalOrgID == nil, let internalOrgID { + fallbackInternalOrgID = internalOrgID + } + } + + if let override, fallbackInternalOrgID != nil { + return (override, fallbackInternalOrgID) + } + + if let fallbackSlug { + return ("org/\(fallbackSlug)", fallbackInternalOrgID) + } + if let fallbackInternalOrgID { + return ("organizations/\(fallbackInternalOrgID)", fallbackInternalOrgID) + } + + return nil + } + + private static func externalOrgSlug(from key: String) -> String { + guard let range = key.range(of: self.externalOrgPrefix) else { return key } + return String(key[range.upperBound...]) + } + + private static func cleanedOrgID(_ raw: String) -> String? { + let value = self.decodedStorageValue(raw) + guard DevinUsageFetcher.isInternalOrganizationID(value) else { return nil } + return value + } + + private static func cleanedOrgID(_ raw: String?) -> String? { + guard let raw else { return nil } + return self.cleanedOrgID(raw) + } + + private static func cleanedSlug(_ raw: String?) -> String? { + guard let raw else { return nil } + let value = self.decodedStorageValue(raw) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "null", !DevinUsageFetcher.isInternalOrganizationID(value) else { + return nil + } + if value.hasPrefix("org/") { + return String(value.dropFirst(4)) + } + return value + } + + private static func slugFromPostAuthKey(_ key: String) -> String? { + guard let range = key.range(of: "-org_name-") else { return nil } + return String(key[range.upperBound...]) + } + + private static func internalOrgIDFromStorageKey(_ key: String) -> String? { + guard let range = key.range(of: #"org[-_][A-Za-z0-9]{8,}"#, options: .regularExpression) else { + return nil + } + return self.cleanedOrgID(String(key[range])) + } + + private static func firstString(in object: Any?, matching keys: Set) -> String? { + if let dictionary = object as? [String: Any] { + for (key, value) in dictionary { + if keys.contains(key), let string = value as? String, !string.isEmpty { + return string + } + if let found = self.firstString(in: value, matching: keys) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.firstString(in: value, matching: keys) { + return found + } + } + } + + return nil + } + + private static func slug(fromNormalizedOrganization organization: String) -> String? { + guard organization.hasPrefix("org/") else { return nil } + return String(organization.dropFirst(4)) + } + + private static func orgID(fromNormalizedOrganization organization: String) -> String? { + guard organization.hasPrefix("organizations/") else { return nil } + return String(organization.dropFirst("organizations/".count)) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift new file mode 100644 index 000000000..5ae76578c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageFetcher.swift @@ -0,0 +1,271 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct DevinUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.devin) + private static let baseURL = URL(string: "https://app.devin.ai")! + private static let defaultUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + public struct RequestAuth: Sendable, Equatable { + public let bearerToken: String + public let organization: String? + public let internalOrganizationID: String? + public let sourceLabel: String + + public init( + bearerToken: String, + organization: String?, + internalOrganizationID: String?, + sourceLabel: String) + { + self.bearerToken = bearerToken + self.organization = organization + self.internalOrganizationID = internalOrganizationID + self.sourceLabel = sourceLabel + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + bearerTokenOverride: String? = nil, + organizationOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot + { + let auths = try self.resolveAuths( + bearerTokenOverride: bearerTokenOverride, + organizationOverride: organizationOverride, + logger: logger) + var lastError: Error? + for auth in auths { + do { + return try await Self.fetchQuotaUsage( + auth: auth, + organizationOverride: organizationOverride, + timeout: timeout, + logger: logger, + now: now, + transport: transport) + } catch { + lastError = error + logger?("[devin] Session from \(auth.sourceLabel) failed: \(error.localizedDescription)") + if auth.sourceLabel == "manual" || !Self.shouldTryNextSession(after: error) { + throw error + } + } + } + throw lastError ?? DevinUsageError.noSession + } + + public static func fetchQuotaUsage( + auth: RequestAuth, + organizationOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot + { + let organization = self.normalizedOrganization(organizationOverride) ?? + self.normalizedOrganization(auth.organization) + guard let organization else { + throw DevinUsageError.missingOrganization + } + + var lastError: Error? + for path in self.candidatePaths( + organization: organization, + internalOrganizationID: auth.internalOrganizationID) + { + let data: Data + do { + data = try await self.fetch( + path: path, + auth: auth, + timeout: timeout, + transport: transport) + } catch { + lastError = error + logger?("[devin] /api/\(path) failed: \(error.localizedDescription)") + if case DevinUsageError.invalidCredentials = error { + throw error + } + continue + } + logger?("[devin] Fetched quota usage from /api/\(path)") + return try DevinUsageParser.parse(data, organization: organization, now: now) + } + + throw lastError ?? DevinUsageError.apiError("No Devin quota endpoint succeeded.") + } + + public static func manualAuth(from raw: String?, organization: String? = nil) -> RequestAuth? { + guard var token = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + return nil + } + if token.lowercased().hasPrefix("authorization:") { + token = token.dropHeaderName().trimmingCharacters(in: .whitespacesAndNewlines) + } + if token.lowercased().hasPrefix("bearer ") { + token = String(token.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !token.isEmpty else { return nil } + return RequestAuth( + bearerToken: token, + organization: self.normalizedOrganization(organization), + internalOrganizationID: self.internalOrganizationID(from: organization), + sourceLabel: "manual") + } + + private func resolveAuths( + bearerTokenOverride: String?, + organizationOverride: String?, + logger: ((String) -> Void)?) throws -> [RequestAuth] + { + if let manual = Self.manualAuth(from: bearerTokenOverride, organization: organizationOverride) { + logger?("[devin] Using manual Bearer token") + return [manual] + } + + #if os(macOS) + let normalizedOrganizationOverride = Self.normalizedOrganization(organizationOverride) + let sessions = DevinSessionImporter.importSessions( + browserDetection: self.browserDetection, + organizationOverride: normalizedOrganizationOverride, + logger: logger) + guard !sessions.isEmpty else { + throw DevinUsageError.noSession + } + logger?("[devin] Found \(sessions.count) browser session(s)") + return sessions.map { session in + RequestAuth( + bearerToken: session.accessToken, + organization: normalizedOrganizationOverride ?? Self.normalizedOrganization(session.organization), + internalOrganizationID: session.internalOrganizationID, + sourceLabel: session.sourceLabel) + } + #else + throw DevinUsageError.noSession + #endif + } + + static func shouldTryNextSession(after error: Error) -> Bool { + switch error { + case DevinUsageError.invalidCredentials, DevinUsageError.apiError, DevinUsageError.missingOrganization: + true + default: + false + } + } + + private static func fetch( + path: String, + auth: RequestAuth, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> Data + { + let url = self.baseURL.appending(path: "api/\(path)") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.defaultUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(auth.bearerToken)", forHTTPHeaderField: "Authorization") + if let internalOrganizationID = auth.internalOrganizationID { + request.setValue(internalOrganizationID, forHTTPHeaderField: "x-cog-org-id") + } + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + let body = String(data: response.data.prefix(200), encoding: .utf8) ?? "" + if response.statusCode == 401 || response.statusCode == 403 { + throw DevinUsageError.invalidCredentials + } + Self.log.error("Devin API returned \(response.statusCode): \(body)") + throw DevinUsageError.apiError("HTTP \(response.statusCode)") + } + return response.data + } + + private static func candidatePaths(organization: String, internalOrganizationID: String?) -> [String] { + var paths: [String] = [] + let normalized = self.normalizedOrganization(organization) ?? organization + if let internalOrganizationID { + paths.append("\(internalOrganizationID)/billing/quota/usage") + } + paths.append("\(normalized)/billing/quota/usage") + if normalized.hasPrefix("org/") { + let slug = String(normalized.dropFirst(4)) + paths.append("\(slug)/billing/quota/usage") + } + if !normalized.hasPrefix("org/"), !normalized.hasPrefix("organizations/") { + paths.append("org/\(normalized)/billing/quota/usage") + } + if let internalOrganizationID { + paths.append("organizations/\(internalOrganizationID)/billing/quota/usage") + } + return paths.removingDuplicates() + } + + public static func normalizedOrganization(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if let url = URL(string: value), + let host = url.host?.lowercased(), + host == "devin.ai" || host.hasSuffix(".devin.ai") + { + let components = url.path.split(separator: "/").map(String.init) + if components.count >= 2, components[0] == "org" { + value = "org/\(components[1])" + } else if components.count >= 2, components[0] == "organizations" { + value = "organizations/\(components[1])" + } + } + value = value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if value.hasPrefix("org/") || value.hasPrefix("organizations/") { + return value + } + if self.isInternalOrganizationID(value) { + return "organizations/\(value)" + } + return "org/\(value)" + } + + private static func internalOrganizationID(from raw: String?) -> String? { + guard let normalized = self.normalizedOrganization(raw), + normalized.hasPrefix("organizations/") + else { + return nil + } + return String(normalized.dropFirst("organizations/".count)) + } + + static func isInternalOrganizationID(_ value: String) -> Bool { + value.hasPrefix("org-") || value.hasPrefix("org_") + } +} + +extension String { + fileprivate func dropHeaderName() -> String { + guard let index = self.firstIndex(of: ":") else { return self } + return String(self[self.index(after: index)...]) + } +} + +extension [String] { + fileprivate func removingDuplicates() -> [String] { + var seen = Set() + return self.filter { seen.insert($0).inserted } + } +} diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift new file mode 100644 index 000000000..53bbcc50c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift @@ -0,0 +1,320 @@ +import CoreFoundation +import Foundation + +public enum DevinUsageError: LocalizedError, Sendable { + case noSession + case missingOrganization + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noSession: + "No Devin browser session found. Please log in to app.devin.ai or paste a Bearer token." + case .missingOrganization: + "No Devin organization was found. Open an app.devin.ai/org/... page " + + "or set the organization in Devin settings." + case .invalidCredentials: + "Devin session token is invalid or expired." + case let .apiError(message): + "Devin API error: \(message)" + case let .parseFailed(message): + "Could not parse Devin usage: \(message)" + } + } +} + +public struct DevinQuotaWindow: Sendable, Equatable { + public let usedPercent: Double + public let resetsAt: Date? + + public init(usedPercent: Double, resetsAt: Date? = nil) { + self.usedPercent = min(100, max(0, usedPercent)) + self.resetsAt = resetsAt + } +} + +public struct DevinUsageSnapshot: Sendable, Equatable { + public let daily: DevinQuotaWindow? + public let weekly: DevinQuotaWindow? + public let planName: String? + public let organization: String? + public let updatedAt: Date + + public init( + daily: DevinQuotaWindow?, + weekly: DevinQuotaWindow?, + planName: String?, + organization: String?, + updatedAt: Date) + { + self.daily = daily + self.weekly = weekly + self.planName = planName + self.organization = organization + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.daily.map { + RateWindow( + usedPercent: $0.usedPercent, + windowMinutes: 24 * 60, + resetsAt: $0.resetsAt, + resetDescription: "Daily") + } + let secondary = self.weekly.map { + RateWindow( + usedPercent: $0.usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: $0.resetsAt, + resetDescription: "Weekly") + } + let identity = ProviderIdentitySnapshot( + providerID: .devin, + accountEmail: nil, + accountOrganization: self.organization, + loginMethod: self.planName) + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum DevinUsageParser { + public static func parse(_ data: Data, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot { + let object = try JSONSerialization.jsonObject(with: data) + return try self.parse(object, organization: organization, now: now) + } + + public static func parse(_ object: Any, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot { + let current = (object as? [String: Any]).map(self.currentQuotaWindows) + let daily = current?.daily ?? self.findWindow(in: object, matching: self.isDailyKey) + let weekly = current?.weekly ?? self.findWindow(in: object, matching: self.isWeeklyKey) + guard daily != nil || weekly != nil else { + throw DevinUsageError.parseFailed("Missing Devin quota windows.") + } + + return DevinUsageSnapshot( + daily: daily, + weekly: weekly, + planName: self.findPlanName(in: object), + organization: self.displayOrganization(from: organization), + updatedAt: now) + } + + private static func currentQuotaWindows(_ dictionary: [String: Any]) + -> (daily: DevinQuotaWindow?, weekly: DevinQuotaWindow?) + { + let daily = self.currentQuotaWindow( + percent: dictionary["daily_percentage"], + resetsAt: dictionary["daily_reset_at"]) + let weekly = self.currentQuotaWindow( + percent: dictionary["weekly_percentage"], + resetsAt: dictionary["weekly_reset_at"]) + return (daily, weekly) + } + + private static func currentQuotaWindow(percent: Any?, resetsAt: Any?) -> DevinQuotaWindow? { + guard let usedPercent = self.double(percent) else { return nil } + return DevinQuotaWindow( + usedPercent: usedPercent <= 1 ? usedPercent * 100 : usedPercent, + resetsAt: self.date(from: resetsAt)) + } + + private static func findWindow(in object: Any, matching keyMatches: (String) -> Bool) -> DevinQuotaWindow? { + if let dictionary = object as? [String: Any] { + for (key, value) in dictionary where keyMatches(key) { + if let window = self.window(from: value) { + return window + } + } + for value in dictionary.values { + if let found = self.findWindow(in: value, matching: keyMatches) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findWindow(in: value, matching: keyMatches) { + return found + } + } + } + + return nil + } + + private static func window(from object: Any) -> DevinQuotaWindow? { + guard let dictionary = object as? [String: Any] else { + guard let percent = self.percent(from: object) else { return nil } + return DevinQuotaWindow(usedPercent: percent, resetsAt: nil) + } + + if let percent = self.percent(from: dictionary) { + return DevinQuotaWindow( + usedPercent: percent, + resetsAt: self.findResetDate(in: dictionary)) + } + + if let nested = dictionary.values.lazy.compactMap({ self.window(from: $0) }).first { + return nested + } + + return nil + } + + private static func percent(from object: Any) -> Double? { + if let number = self.double(object) { + return number <= 1 ? number * 100 : number + } + guard let dictionary = object as? [String: Any] else { return nil } + + let directKeys = [ + "used_percent", + "usedPercent", + "usage_percent", + "usagePercent", + "percent_used", + "percentUsed", + "percent", + ] + for key in directKeys { + if let value = self.double(dictionary[key]) { + return value <= 1 ? value * 100 : value + } + } + + let remainingKeys = ["remaining_percent", "remainingPercent", "percent_remaining", "percentRemaining"] + for key in remainingKeys { + if let value = self.double(dictionary[key]) { + let percent = value <= 1 ? value * 100 : value + return 100 - percent + } + } + + let used = self.firstDouble(in: dictionary, keys: ["used", "usage", "used_count", "usedCount", "consumed"]) + let limit = self.firstDouble(in: dictionary, keys: ["limit", "quota", "total", "max", "available"]) + if let used, let limit, limit > 0 { + return used / limit * 100 + } + + let remaining = self.firstDouble(in: dictionary, keys: ["remaining", "left", "available"]) + if let remaining, let limit, limit > 0 { + return (limit - remaining) / limit * 100 + } + + return nil + } + + private static func findPlanName(in object: Any) -> String? { + if let dictionary = object as? [String: Any] { + for key in ["plan_name", "planName", "plan", "tier", "subscription_tier", "subscriptionTier"] { + if let value = dictionary[key] as? String, + let cleaned = self.cleanDisplay(value) + { + return cleaned + } + } + for value in dictionary.values { + if let found = self.findPlanName(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findPlanName(in: value) { + return found + } + } + } + + return nil + } + + private static func findResetDate(in dictionary: [String: Any]) -> Date? { + for (key, value) in dictionary where key.localizedCaseInsensitiveContains("reset") { + if let date = self.date(from: value) { + return date + } + } + return nil + } + + private static func date(from value: Any?) -> Date? { + if let raw = value as? String { + if let date = ISO8601DateFormatter().date(from: raw) { + return date + } + if let number = Double(raw) { + return self.date(from: number) + } + } + if let number = self.double(value) { + return self.date(from: number) + } + return nil + } + + private static func date(from number: Double) -> Date? { + guard number > 0 else { return nil } + let seconds = number > 10_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + + private static func firstDouble(in dictionary: [String: Any], keys: [String]) -> Double? { + for key in keys { + if let value = self.double(dictionary[key]) { + return value + } + } + return nil + } + + private static func double(_ value: Any?) -> Double? { + switch value { + case let number as NSNumber: + CFGetTypeID(number) == CFBooleanGetTypeID() ? nil : number.doubleValue + case let string as String: + Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func isDailyKey(_ raw: String) -> Bool { + let key = raw.lowercased() + return !key.contains("hide") && (key.contains("daily") || key.contains("day")) + } + + private static func isWeeklyKey(_ raw: String) -> Bool { + let key = raw.lowercased() + return !key.contains("hide") && (key.contains("weekly") || key.contains("week")) + } + + private static func displayOrganization(from raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + if raw.hasPrefix("org/") { + return String(raw.dropFirst(4)) + } + if raw.hasPrefix("organizations/") { + return String(raw.dropFirst("organizations/".count)) + } + return raw + } + + private static func cleanDisplay(_ raw: String) -> String? { + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + return cleaned.split(separator: "_").flatMap { $0.split(separator: "-") }.map { part in + part.prefix(1).uppercased() + String(part.dropFirst()) + }.joined(separator: " ") + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 9bfad55db..be675cd7a 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -10,13 +10,15 @@ public struct DoubaoUsageSnapshot: Sendable { public let updatedAt: Date public let apiKeyValid: Bool public let totalTokens: Int? + public let requestLimitsReliable: Bool public init( remainingRequests: Int, limitRequests: Int, resetTime: Date?, updatedAt: Date, apiKeyValid: Bool = false, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + requestLimitsReliable: Bool = true) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -24,13 +26,14 @@ public struct DoubaoUsageSnapshot: Sendable { self.updatedAt = updatedAt self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens + self.requestLimitsReliable = requestLimitsReliable } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double let resetDescription: String - if self.limitRequests > 0 { + if self.limitRequests > 0, self.requestLimitsReliable { let used = max(0, self.limitRequests - self.remainingRequests) usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) resetDescription = "\(used)/\(self.limitRequests) requests" @@ -96,7 +99,22 @@ public struct DoubaoUsageFetcher: Sendable { "doubao-lite-32k", ] - public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot { + private struct ProbeResult { + let snapshot: DoubaoUsageSnapshot + let statusCode: Int + + var hasAmbiguousZeroRemaining: Bool { + self.statusCode == 200 + && self.snapshot.requestLimitsReliable + && self.snapshot.limitRequests > 0 + && self.snapshot.remainingRequests == 0 + } + } + + public static func fetchUsage( + apiKey: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DoubaoUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DoubaoUsageError.missingCredentials } @@ -104,7 +122,16 @@ public struct DoubaoUsageFetcher: Sendable { var lastError: Error? for model in self.probeModels { do { - return try await self.probe(apiKey: apiKey, model: model) + let result = try await self.probe(apiKey: apiKey, model: model, transport: transport) + guard result.hasAmbiguousZeroRemaining else { + return result.snapshot + } + + return try await self.confirmAmbiguousZeroRemaining( + initial: result, + apiKey: apiKey, + model: model, + transport: transport) } catch let error as DoubaoUsageError { if case let .apiError(code, _) = error, code == 404 || code == 403 { Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next") @@ -117,7 +144,57 @@ public struct DoubaoUsageFetcher: Sendable { throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") } - private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot { + private static func confirmAmbiguousZeroRemaining( + initial: ProbeResult, + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async throws -> DoubaoUsageSnapshot + { + do { + let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) + // This path starts only after a complete HTTP 200 request-limit pair + // reported zero. An immediate 429 confirms that exhausted state even + // when Ark omits the headers from the throttle response. + if confirmation.statusCode == 429 { + return confirmation.snapshot.requestLimitsReliable + ? confirmation.snapshot + : initial.snapshot + } + guard confirmation.hasAmbiguousZeroRemaining else { + return confirmation.snapshot + } + + Self.log.warning( + """ + Doubao Ark returned limit=\(confirmation.snapshot.limitRequests) remaining=0 \ + with HTTP 200 twice; treating request-limit headers as unreliable. + """) + return DoubaoUsageSnapshot( + remainingRequests: confirmation.snapshot.remainingRequests, + limitRequests: confirmation.snapshot.limitRequests, + resetTime: confirmation.snapshot.resetTime, + updatedAt: confirmation.snapshot.updatedAt, + apiKeyValid: confirmation.snapshot.apiKeyValid, + totalTokens: confirmation.snapshot.totalTokens, + requestLimitsReliable: false) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw error + } + self.log.warning( + """ + Doubao zero-remaining confirmation failed; preserving the initial exhausted state: \ + \(error.localizedDescription) + """) + return initial.snapshot + } + } + + private static func probe( + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async throws -> ProbeResult + { var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.timeoutInterval = 15 @@ -135,7 +212,7 @@ public struct DoubaoUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers. @@ -163,6 +240,11 @@ public struct DoubaoUsageFetcher: Sendable { // 429 means the key is valid but rate-limited; treat it as valid so the UI // shows "Active" instead of "No usage data" when headers are absent. let keyValid = response.statusCode == 200 || response.statusCode == 429 + // A request-limit header on 429 identifies request-bucket exhaustion even + // when Ark omits remaining. A bare 429 may describe another throttle. + let requestLimitsReliable = response.statusCode == 429 + ? limit != nil + : limit != nil && remaining != nil let snapshot = DoubaoUsageSnapshot( remainingRequests: remaining ?? 0, @@ -170,7 +252,8 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens) + totalTokens: totalTokens, + requestLimitsReliable: requestLimitsReliable) Self.log.debug( """ @@ -178,7 +261,7 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) - return snapshot + return ProbeResult(snapshot: snapshot, statusCode: response.statusCode) } private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? { diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift index 6cb2470af..857fbe10d 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -129,8 +129,26 @@ public enum MiMoCookieImporter { return try override(browserDetection, logger) } + return try self.importSessions( + browserDetection: browserDetection, + logger: logger, + loadRecords: { browserSource, query, log in + try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + }) + } + + static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil, + loadRecords: (Browser, BrowserCookieQuery, ((String) -> Void)?) throws + -> [BrowserCookieStoreRecords]) throws -> [SessionInfo] + { let log: (String) -> Void = { msg in logger?("[mimo-cookie] \(msg)") } var sessions: [SessionInfo] = [] + var accessDeniedHints: [String] = [] let installed = miMoCookieImportOrder.cookieImportCandidates(using: browserDetection) let labels = installed.map(\.displayName).joined(separator: ", ") log("Cookie import candidates: \(labels)") @@ -138,17 +156,24 @@ public enum MiMoCookieImporter { for browserSource in installed { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( - matching: query, - in: browserSource, - logger: log) + let sources = try loadRecords(browserSource, query, log) sessions.append(contentsOf: self.sessionInfos(from: sources, origin: query.origin)) + } catch let error as BrowserCookieError { + BrowserCookieAccessGate.recordIfNeeded(error) + if let hint = error.accessDeniedHint { + accessDeniedHints.append(hint) + } + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } + if sessions.isEmpty, !accessDeniedHints.isEmpty { + let details = Array(Set(accessDeniedHints)).sorted().joined(separator: " ") + throw MiMoSettingsError.missingCookie(details: details) + } return sessions } diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index 7493282d4..f1513f0ca 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -1,25 +1,11 @@ import CodexBarMacroSupport import Foundation -#if os(macOS) -import SweetCookieKit -#endif - @ProviderDescriptorRegistration @ProviderDescriptorDefinition public enum MiMoProviderDescriptor { static func makeDescriptor() -> ProviderDescriptor { - #if os(macOS) - let browserOrder: BrowserCookieImportOrder = [ - .chrome, - .chromeBeta, - .chromeCanary, - ] - #else - let browserOrder: BrowserCookieImportOrder? = nil - #endif - - return ProviderDescriptor( + ProviderDescriptor( id: .mimo, metadata: ProviderMetadata( id: .mimo, @@ -35,7 +21,7 @@ public enum MiMoProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - browserCookieOrder: browserOrder, + browserCookieOrder: ProviderBrowserCookieDefaults.mimoCookieImportOrder, dashboardURL: "https://platform.xiaomimimo.com/#/console/balance", statusPageURL: nil), branding: ProviderBranding( @@ -64,25 +50,13 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { if context.settings?.mimo?.cookieSource == .manual { return Self.resolveManualCookieHeader(context: context) != nil } - if Self.resolveManualCookieHeader(context: context) != nil { - return true - } - - #if os(macOS) - if let cached = CookieHeaderCache.load(provider: .mimo), - MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) != nil - { - return true - } - return MiMoCookieImporter.hasSession(browserDetection: context.browserDetection) - #else - return false - #endif + // Fetch resolves the session so missing-cookie and browser-permission errors stay actionable. + return true } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { guard context.settings?.mimo?.cookieSource != .off else { - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() } if context.settings?.mimo?.cookieSource == .manual { guard let manualCookie = Self.resolveManualCookieHeader(context: context) else { @@ -123,7 +97,7 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { let sessions = try MiMoCookieImporter.importSessions(browserDetection: context.browserDetection) guard !sessions.isEmpty else { if let lastError { throw lastError } - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() } for session in sessions { @@ -146,9 +120,9 @@ struct MiMoWebFetchStrategy: ProviderFetchStrategy { } if let lastError { throw lastError } - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() #else - throw MiMoSettingsError.missingCookie + throw MiMoSettingsError.missingCookie() #endif } diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift index 7f443d587..446e42910 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift @@ -3,14 +3,19 @@ import Foundation import FoundationNetworking #endif -public enum MiMoSettingsError: LocalizedError, Sendable { - case missingCookie +public enum MiMoSettingsError: LocalizedError, Sendable, Equatable { + case missingCookie(details: String? = nil) case invalidCookie public var errorDescription: String? { switch self { - case .missingCookie: - "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first." + case let .missingCookie(details): + [ + "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first.", + details, + ] + .compactMap(\.self) + .joined(separator: " ") case .invalidCookie: "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies." } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 56a2d45c0..37f62f755 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -3,7 +3,7 @@ import Foundation import FoundationNetworking #endif -struct MiniMaxSubscriptionMetadata: Sendable, Equatable { +struct MiniMaxSubscriptionMetadata: Equatable { let planName: String? let subscriptionExpiresAt: Date? let subscriptionRenewsAt: Date? diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 21e058dfc..f0fe02ef8 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -66,6 +66,7 @@ public enum ProviderDescriptorRegistry { .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, + .devin: DevinProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, .manus: ManusProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 71a29e7f5..af033f4b4 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -22,6 +22,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, windsurf: WindsurfProviderSettings? = nil, @@ -52,6 +53,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: moonshot, amp: amp, t3chat: t3chat, + devin: devin, ollama: ollama, jetbrains: jetbrains, windsurf: windsurf, @@ -277,6 +279,18 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct DevinProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualBearerToken: String? + public let organization: String? + + public init(cookieSource: ProviderCookieSource, manualBearerToken: String?, organization: String?) { + self.cookieSource = cookieSource + self.manualBearerToken = manualBearerToken + self.organization = organization + } + } + public struct CommandCodeProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -392,6 +406,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? public let t3chat: T3ChatProviderSettings? + public let devin: DevinProviderSettings? public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? @@ -427,6 +442,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, @@ -457,6 +473,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.moonshot = moonshot self.amp = amp self.t3chat = t3chat + self.devin = devin self.commandcode = commandcode self.ollama = ollama self.jetbrains = jetbrains @@ -488,6 +505,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case devin(ProviderSettingsSnapshot.DevinProviderSettings) case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) @@ -520,6 +538,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var devin: ProviderSettingsSnapshot.DevinProviderSettings? public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? @@ -556,6 +575,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value case let .t3chat(value): self.t3chat = value + case let .devin(value): self.devin = value case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value @@ -590,6 +610,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { moonshot: self.moonshot, amp: self.amp, t3chat: self.t3chat, + devin: self.devin, commandcode: self.commandcode, ollama: self.ollama, jetbrains: self.jetbrains, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 0727067ab..6678c6cc9 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -16,6 +16,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case gemini case antigravity case copilot + case devin case zai case minimax case manus @@ -70,6 +71,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case alibaba case factory case copilot + case devin case kimi case kimik2 case kilo @@ -220,4 +222,25 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// MiMo Auto: Safari first (no Keychain prompt), keep the existing Chrome-family + /// entries from main, and add Firefox/Edge per #1304. Other Chromium forks stay on + /// Manual import to avoid scanning the full SweetCookieKit default order. + public static var mimoCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge] + #else + nil + #endif + } + + /// Devin sessions are normally in Chrome. Keep automatic import narrow so live probes do not + /// touch unrelated browser keychains; users can select another browser explicitly. + public static var devinCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift index d0056fc71..86ad48f26 100644 --- a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift +++ b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift @@ -71,7 +71,9 @@ public enum AccountIdentityComputer { // Upstream v0.28.0–v0.29.0 new providers. iOS 1.9 surfaces // these via single-account cards. Promote to Tier-A only if a // user files a cross-Mac merging request for them. - .azureopenai, .alibabatokenplan, .t3chat: + .azureopenai, .alibabatokenplan, .t3chat, + // Upstream 0.33 new provider. Same rationale as above. + .devin: // Non-Tier-A providers: no stable account model required by // iOS today. Return nil → iOS falls back to per-device legacy // bucket. If a future provider needs cross-Mac merging, add diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index b829f0264..56e80d8d8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,10 @@ import Foundation enum CostUsageCacheIO { + private static let compatibleCodexProducerKeys: Set = [ + "codex:cu:p3c27f997569eb3c5", + ] + private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: @@ -10,11 +14,10 @@ enum CostUsageCacheIO { // fork's prior 4 → 5 → 6 history. 8 case .claude, .vertexai: - // Bumped 2 → 3 alongside the codex bump for consistency: the - // claude pricing table also gained `claude-opus-4-7` and the - // fallback resolver, so any cached cost rows from 0.20.3 era - // need to be re-derived under the new pricing. - 3 + // Upstream bumped to 4 with the 0.33 Claude pricing correction + // (Fable 5 rates, native 1-hour cache writes, Sonnet 4.6 + // full-context rates). Supersedes fork's prior 2 → 3 history. + 4 default: 1 } @@ -41,10 +44,14 @@ enum CostUsageCacheIO { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedFingerprint = CostUsagePricing.pricingFingerprint let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) + let compatibleProducerKeys = producerKey == nil && provider == .codex + ? self.compatibleCodexProducerKeys + : [] if let decoded = self.loadCache( at: url, expectedFingerprint: expectedFingerprint, - expectedProducerKey: expectedProducerKey) + expectedProducerKey: expectedProducerKey, + compatibleProducerKeys: compatibleProducerKeys) { return decoded } @@ -58,7 +65,8 @@ enum CostUsageCacheIO { private static func loadCache( at url: URL, expectedFingerprint: String, - expectedProducerKey: String?) -> CostUsageCache? + expectedProducerKey: String?, + compatibleProducerKeys: Set) -> CostUsageCache? { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) @@ -76,7 +84,9 @@ enum CostUsageCacheIO { // even when the pricing fingerprint is unchanged. Validate both so a // stale cache is discarded if EITHER signal moves. if let expectedProducerKey { - guard decoded.producerKey == expectedProducerKey else { return nil } + guard decoded.producerKey == expectedProducerKey + || decoded.producerKey.map(compatibleProducerKeys.contains) == true + else { return nil } } return decoded } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 9ee2ca2c7..41bf28874 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -57,6 +57,14 @@ enum CostUsagePricing { let cacheReadInputCostPerTokenAboveThreshold: Double? } + private struct ClaudeCostTokens { + let input: Int + let cacheRead: Int + let cacheCreation: Int + let cacheCreation1h: Int + let output: Int + } + private static let codex: [String: CodexPricing] = [ "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, @@ -205,6 +213,16 @@ enum CostUsagePricing { } private static let claude: [String: ClaudePricing] = [ + "claude-fable-5": ClaudePricing( + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheCreationInputCostPerToken: 1.25e-5, + cacheReadInputCostPerToken: 1e-6, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-haiku-4-5-20251001": ClaudePricing( inputCostPerToken: 1e-6, outputCostPerToken: 5e-6, @@ -300,11 +318,11 @@ enum CostUsagePricing { outputCostPerToken: 1.5e-5, cacheCreationInputCostPerToken: 3.75e-6, cacheReadInputCostPerToken: 3e-7, - thresholdTokens: 200_000, - inputCostPerTokenAboveThreshold: 6e-6, - outputCostPerTokenAboveThreshold: 2.25e-5, - cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, - cacheReadInputCostPerTokenAboveThreshold: 6e-7), + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5-20250929": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -347,6 +365,30 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) + private static let claudeHistoricalLongContext: [String: ClaudePricing] = [ + "claude-opus-4-6": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 3.75e-5, + cacheCreationInputCostPerTokenAboveThreshold: 1.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 6e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, + cacheReadInputCostPerTokenAboveThreshold: 6e-7), + ] + private static let codexModelsDevProviderID = "openai" private static let claudeModelsDevProviderID = "anthropic" @@ -363,6 +405,15 @@ enum CostUsagePricing { /// `CostUsageJsonl.swift` change vs origin/mobile-dev. /// /// History: + /// - `6` (0.33.1 sync): merged upstream v0.32.4→0.33.1-dev cost-scanner + /// changes — Claude parser now splits native 1-hour cache-write usage + /// (`ClaudeCostTokens.cacheCreation1h`) under corrected Claude pricing + /// (#1368/#1372), threads `pricingDate` for dated historical + /// long-context rates, and Codex scans moved to the dedicated scan + /// executor with reduced metadata work (#1392/#1430). The regenerated + /// parser hash rolls the Codex producerKey axis; this bump rolls the + /// pricingFingerprint so Claude caches (no producerKey) written by the + /// v0.32.4 parser are invalidated and re-scanned. /// - `5` (0.32.4.1): merged upstream v0.32.0→v0.32.4 Codex cost-scanner /// rewrite (new `CostUsageScanner+CodexFastJSON.swift`, reworked truncated-prefix /// handling, scan-perf changes). The regenerated parser hash rolls the Codex @@ -392,7 +443,7 @@ enum CostUsagePricing { /// in `parseCodexFile`. Bumping rolls every previous version's /// cache and re-scans with the fixed parser. /// - `1` (0.23.1): initial fingerprint contract. - static let parserLogicVersion = 5 + static let parserLogicVersion = 6 /// Stable string fingerprint of the pricing tables + parser logic. /// `CostUsageCacheIO.load` compares this against the value stored @@ -671,10 +722,32 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, + cacheCreationInputTokens1h: Int = 0, outputTokens: Int, + pricingDate: Date? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> Double? { + let tokens = ClaudeCostTokens( + input: inputTokens, + cacheRead: cacheReadInputTokens, + cacheCreation: cacheCreationInputTokens, + cacheCreation1h: cacheCreationInputTokens1h, + output: outputTokens) + let key = self.normalizeClaudeModel(model) + + // 0) Dated rows: historical long-context pricing (upstream 0.33). + if let pricingDate, + let historicalPricing = self.claudeHistoricalLongContext[key], + let currentPricing = self.claude[key] + { + return self.claudeCostUSD( + pricing: pricingDate < self.claudeFullContextStandardPricingCutoff + ? historicalPricing + : currentPricing, + tokens: tokens) + } + // 1) models.dev catalog (upstream 0.25). if let lookup = self.modelsDevLookup( providerID: self.claudeModelsDevProviderID, @@ -684,75 +757,58 @@ enum CostUsagePricing { { return self.claudeCostUSD( pricing: lookup.pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } // 2) Exact local-table hit. - let key = self.normalizeClaudeModel(model) if let pricing = self.claude[key] { return self.claudeCostUSD( pricing: pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } // 3) Fork's family-fallback ladder (Research/018). guard let pricing = self.resolveClaudePricing(model: model) else { return nil } return self.claudeCostUSD( pricing: pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } private static func claudeCostUSD( pricing: ClaudePricing, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { - func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { - guard let threshold, let above else { return Double(tokens) * base } - let below = min(tokens, threshold) - let over = max(tokens - threshold, 0) - return Double(below) * base + Double(over) * above - } + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreationTotal = max(0, tokens.cacheCreation) + let cacheCreation1h = min(max(0, tokens.cacheCreation1h), cacheCreationTotal) + let cacheCreation5m = cacheCreationTotal - cacheCreation1h + let usesLongContextRates = pricing.thresholdTokens.map { + input + cacheRead + cacheCreationTotal > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let cacheCreation5mRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold ?? pricing.cacheCreationInputCostPerToken + : pricing.cacheCreationInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken - return tiered( - max(0, inputTokens), - base: pricing.inputCostPerToken, - above: pricing.inputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheReadInputTokens), - base: pricing.cacheReadInputCostPerToken, - above: pricing.cacheReadInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheCreationInputTokens), - base: pricing.cacheCreationInputCostPerToken, - above: pricing.cacheCreationInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, outputTokens), - base: pricing.outputCostPerToken, - above: pricing.outputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(cacheCreation5m) * cacheCreation5mRate + + Double(cacheCreation1h) * inputRate * 2 + + Double(max(0, tokens.output)) * outputRate } private static func claudeCostUSD( pricing: ModelsDevPricingInfo, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { self.claudeCostUSD( pricing: ClaudePricing( @@ -765,10 +821,7 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } /// Returns true iff the given raw Claude model name maps to an exact diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 64d351eb9..d6744254c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1,4 +1,9 @@ import Foundation +#if os(Linux) +import Glibc +#else +import Darwin +#endif extension CostUsageScanner { static func codexRowsByDayModel( @@ -617,14 +622,22 @@ extension CostUsageScanner { static func codexFileMetadata(fileURL: URL) -> CodexFileMetadata { let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + return CodexFileMetadata(path: path, mtimeUnixMs: 0, size: 0, fileId: nil) + } + #if os(Linux) + let modifiedSeconds = Int64(info.st_mtim.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + #else + let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + #endif return CodexFileMetadata( path: path, - mtimeUnixMs: Int64(mtime * 1000), - size: size, - fileId: Self.fileIdentityString(fileURL: fileURL)) + mtimeUnixMs: modifiedSeconds * 1000 + modifiedNanoseconds / 1_000_000, + size: Int64(info.st_size), + fileId: "\(info.st_dev):\(info.st_ino)") } static func dropCachedCodexFile( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 3d1578e38..83730d3b1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -3,6 +3,27 @@ import Foundation extension CostUsageScanner { // MARK: - Claude + private struct ClaudeTokens { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int + let output: Int + let costNanos: Int + let costPriced: Bool + } + + private struct ClaudeDayModelKey: Hashable { + let day: String + let model: String + } + + private struct ClaudeRepricedCost { + var total: Double = 0 + var sampleCount: Int = 0 + var unresolved = false + } + private static func defaultClaudeProjectsRoots(options: Options) -> [URL] { if let override = options.claudeProjectsRoots { return override } @@ -59,21 +80,12 @@ extension CostUsageScanner { modelsDevCacheRoot: URL? = nil, checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult { - struct ClaudeTokens: Sendable { - let input: Int - let cacheRead: Int - let cacheCreate: Int - let output: Int - let costNanos: Int - let costPriced: Bool - } - func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate @@ -81,6 +93,7 @@ extension CostUsageScanner { packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos packed[5] = (packed[safe: 5] ?? 0) + 1 packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + tokens.cacheCreate1h dayModels[normModel] = packed days[dayKey] = dayModels } @@ -127,7 +140,8 @@ extension CostUsageScanner { else { return } guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - guard let tsText = obj["timestamp"] as? String else { return } + guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) + else { return } guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } @@ -137,6 +151,9 @@ extension CostUsageScanner { let input = max(0, toInt(usage["input_tokens"])) let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( + usage: usage, + total: cacheCreate) let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) let output = max(0, toInt(usage["output_tokens"])) if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } @@ -146,7 +163,9 @@ extension CostUsageScanner { inputTokens: input, cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, + cacheCreationInputTokens1h: cacheCreate1h, outputTokens: output, + pricingDate: timestamp, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 @@ -154,6 +173,7 @@ extension CostUsageScanner { input: input, cacheRead: cacheRead, cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, output: output, costNanos: costNanos, costPriced: cost != nil) @@ -177,11 +197,13 @@ extension CostUsageScanner { sessionId: sessionId, messageId: messageId, requestId: requestId, + timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), isSidechain: toBool(obj["isSidechain"]), pathRole: pathRole, input: tokens.input, cacheRead: tokens.cacheRead, cacheCreate: tokens.cacheCreate, + cacheCreate1h: tokens.cacheCreate1h, output: tokens.output, costNanos: tokens.costNanos, costPriced: tokens.costPriced) @@ -210,6 +232,7 @@ extension CostUsageScanner { input: row.input, cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h ?? 0, output: row.output, costNanos: row.costNanos, costPriced: row.costPriced ?? (row.costNanos > 0)) @@ -219,6 +242,12 @@ extension CostUsageScanner { return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes) } + private static func claudeOneHourCacheCreationTokens(usage: [String: Any], total: Int) -> Int { + guard let cacheCreation = usage["cache_creation"] as? [String: Any] else { return 0 } + let tokens = (cacheCreation["ephemeral_1h_input_tokens"] as? NSNumber)?.intValue ?? 0 + return min(total, max(0, tokens)) + } + private static func claudePathRole(fileURL: URL) -> ClaudePathRole { fileURL.path.contains("/subagents/") ? .subagent : .parent } @@ -270,29 +299,15 @@ extension CostUsageScanner { return lhs.path < rhs.path } - private static func rebuildClaudeDays(cache: inout CostUsageCache) { - var days: [String: [String: [Int]]] = [:] + private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + var rows: [ClaudeUsageRow] = [] var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] - func addRow(_ row: ClaudeUsageRow) { - var dayModels = days[row.dayKey] ?? [:] - var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + row.input - packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead - packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate - packed[3] = (packed[safe: 3] ?? 0) + row.output - packed[4] = (packed[safe: 4] ?? 0) + row.costNanos - packed[5] = (packed[safe: 5] ?? 0) + 1 - packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) - dayModels[row.model] = packed - days[row.dayKey] = dayModels - } - for path in cache.files.keys.sorted() { - guard let rows = cache.files[path]?.claudeRows else { continue } - for row in rows { + guard let fileRows = cache.files[path]?.claudeRows else { continue } + for row in fileRows { guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { - addRow(row) + rows.append(row) continue } let candidate = (path: path, row: row) @@ -306,8 +321,26 @@ extension CostUsageScanner { } } - for winner in winners.values { - addRow(winner.row) + rows.append(contentsOf: winners.keys.sorted().compactMap { winners[$0]?.row }) + return rows + } + + private static func rebuildClaudeDays(cache: inout CostUsageCache) { + var days: [String: [String: [Int]]] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + var dayModels = days[row.dayKey] ?? [:] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + row.input + packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead + packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate + packed[3] = (packed[safe: 3] ?? 0) + row.output + packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + (row.cacheCreate1h ?? 0) + dayModels[row.model] = packed + days[row.dayKey] = dayModels } cache.days = days @@ -681,6 +714,41 @@ extension CostUsageScanner { var totalCost: Double = 0 var costSeen = false let costScale = 1_000_000_000.0 + var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) + var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() + aggregate.sampleCount += 1 + let isPriced = row.costPriced ?? (row.costNanos > 0) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, + outputTokens: row.output, + pricingDate: row.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + }, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let cost: Double? = if isPriced, row.costNanos == 0 { + 0 + } else if let currentPricingCost { + currentPricingCost + } else if isPriced { + Double(row.costNanos) / costScale + } else { + nil + } + if let cost { + aggregate.total += cost + } else { + aggregate.unresolved = true + } + repricedCosts[key] = aggregate + } let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -705,10 +773,7 @@ extension CostUsageScanner { let cacheRead = packed[safe: 1] ?? 0 let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 - let cachedCost = packed[safe: 4] ?? 0 let sampleCount = packed[safe: 5] ?? 0 - let pricedSampleCount = packed[safe: 6] ?? 0 - let hasCompleteCachedCost = sampleCount > 0 && pricedSampleCount == sampleCount let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. @@ -717,16 +782,16 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let currentPricingCost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - // Cached costs are accumulated per request, which preserves Claude long-context threshold boundaries. - let cost = hasCompleteCachedCost ? Double(cachedCost) / costScale : currentPricingCost + let repricedCost = repricedCosts[ClaudeDayModelKey(day: day, model: model)] + let currentPricingCost: Double? = if let repricedCost, + repricedCost.sampleCount == sampleCount, + !repricedCost.unresolved + { + repricedCost.total + } else { + nil + } + let cost = currentPricingCost breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 9e18fb04e..da326ecf8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -381,11 +381,13 @@ enum CostUsageScanner { let sessionId: String? let messageId: String? let requestId: String? + let timestampUnixMs: Int64? let isSidechain: Bool let pathRole: ClaudePathRole let input: Int let cacheRead: Int let cacheCreate: Int + let cacheCreate1h: Int? let output: Int let costNanos: Int let costPriced: Bool? @@ -447,10 +449,10 @@ enum CostUsageScanner { checkCancellation: checkCancellation) case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .alibabatokenplan, .factory, - .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, - .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, - .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, - .llmproxy, .deepgram: + .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, + .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, + .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, + .groq, .llmproxy, .deepgram: return emptyReport } } @@ -538,9 +540,11 @@ enum CostUsageScanner { private static func cachedCodexSessionFiles( cache: CostUsageCache, range: CostUsageDayRange, - roots: [URL]) -> [URL] + roots: [URL], + excludingPaths: Set) -> [URL] { cache.files.compactMap { path, usage in + guard !excludingPaths.contains(path) else { return nil } let hasRelevantDay = usage.days.keys.contains { CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) } @@ -552,10 +556,18 @@ enum CostUsageScanner { } } - private static func cachedCodexSessionIndex(cache: CostUsageCache, roots: [URL]) -> [String: URL] { + private static func cachedCodexSessionIndex( + cache: CostUsageCache, + roots: [URL], + knownExistingPaths: Set) -> [String: URL] + { var out: [String: URL] = [:] for (path, usage) in cache.files { guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + if knownExistingPaths.contains(path) { + out[sessionId] = URL(fileURLWithPath: path) + continue + } guard FileManager.default.fileExists(atPath: path) else { continue } let fileURL = URL(fileURLWithPath: path) guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } @@ -921,15 +933,6 @@ enum CostUsageScanner { return String(filename[matchRange]) } - static func fileIdentityString(fileURL: URL) -> String? { - guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil } - guard let identifier = values.fileResourceIdentifier else { return nil } - if let data = identifier as? Data { - return data.base64EncodedString() - } - return String(describing: identifier) - } - private struct CodexSessionMetadata { let sessionId: String? let forkedFromId: String? @@ -2297,9 +2300,12 @@ enum CostUsageScanner { } } - for fileURL in Self.cachedCodexSessionFiles(cache: cache, range: range, roots: plan.roots) + for fileURL in Self.cachedCodexSessionFiles( + cache: cache, + range: range, + roots: plan.roots, + excludingPaths: seenPaths) .sorted(by: { $0.path < $1.path }) - where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) @@ -2310,7 +2316,10 @@ enum CostUsageScanner { let fileIndex = CodexSessionFileIndex( files: files, roots: plan.roots, - cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots), + cachedSessionFiles: Self.cachedCodexSessionIndex( + cache: cache, + roots: plan.roots, + knownExistingPaths: filePathsInScan), checkCancellation: checkCancellation) let inheritedResolver = CodexInheritedTotalsResolver( fileIndex: fileIndex, diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index aa3c5cb34..325640618 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -68,6 +68,7 @@ enum ProviderChoice: String, AppEnum { case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot + case .devin: return nil // Devin not yet supported in widgets case .minimax: self = .minimax case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 68fd97228..8e1606cf7 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -277,6 +277,7 @@ private struct ProviderSwitchChip: View { case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" + case .devin: "Devin" case .minimax: "MiniMax" case .manus: "Manus" case .vertexai: "Vertex" @@ -673,6 +674,8 @@ enum WidgetColors { Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange case .copilot: Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple + case .devin: + Color(red: 70 / 255, green: 180 / 255, blue: 130 / 255) case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) case .manus: diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index bd71cbd2b..0cddce66e 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -977,7 +977,7 @@ extension AntigravityStatusProbeTests { } @Test - func `model without remaining fraction keeps reset time`() throws { + func `model without remaining fraction stays out of family summary and preserves reset metadata`() throws { let resetTime = Date(timeIntervalSince1970: 1_735_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -998,9 +998,12 @@ extension AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 0) - #expect(usage.secondary?.resetsAt == resetTime) + #expect(usage.secondary == nil) #expect(usage.tertiary?.remainingPercent.rounded() == 100) + let modelWindow = try #require(usage.extraRateWindows?.first { + $0.id == "MODEL_PLACEHOLDER_M36" + }) + #expect(modelWindow.window.resetsAt == resetTime) } @Test diff --git a/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift new file mode 100644 index 000000000..889da0650 --- /dev/null +++ b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AugmentProviderRuntimeTests { + @Test + func `repeated stop only reports a running keepalive once`() throws { + let suite = "AugmentProviderRuntimeTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore()) + let metadata = try #require(ProviderRegistry.shared.metadata[.augment]) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: true) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let runtime = AugmentProviderRuntime() + let context = ProviderRuntimeContext(provider: .augment, settings: settings, store: store) + defer { runtime.stop(context: context) } + + runtime.start(context: context) + #expect(runtime._test_isKeepaliveRunning) + runtime.stop(context: context) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: false) + runtime.stop(context: context) + runtime.settingsDidChange(context: context) + + #expect(!runtime._test_isKeepaliveRunning) + #expect(runtime._test_keepaliveStopCount == 1) + } +} diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index ac46fe826..d2bdd21d6 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -23,6 +23,16 @@ struct BrowserCookieOrderStatusStringTests { #expect(message.contains(order.loginHint)) } + @Test + func `cursor no session shows full disk access hint before browser list`() throws { + let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder + let message = try #require(CursorStatusProbeError.noSessionCookie.errorDescription) + let fullDiskAccessRange = try #require(message.range(of: CursorStatusProbeError.safariFullDiskAccessHint)) + let browserListRange = try #require(message.range(of: order.loginHint)) + + #expect(fullDiskAccessRange.lowerBound < browserListRange.lowerBound) + } + @Test func `factory no session includes browser login hint`() { let order = ProviderDefaults.metadata[.factory]?.browserCookieOrder ?? Browser.defaultImportOrder @@ -42,5 +52,17 @@ struct BrowserCookieOrderStatusStringTests { func `opencode automatic cookies keep chrome only default`() { #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome]) } + + @Test + func `mimo cookie import order supports safari firefox and edge`() { + let order = ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder + #expect(order == ProviderBrowserCookieDefaults.mimoCookieImportOrder) + #expect(order == [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge]) + #expect(order.first == .safari) + #expect(order.contains(.firefox)) + #expect(order.contains(.edge)) + #expect(!order.contains(.arc)) + } + #endif } diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index 03e098e2c..9eab25921 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -58,6 +58,18 @@ struct ClaudeOAuthKeychainAccessGateTests { #expect(KeychainAccessGate.isDisabled) } + @Test + func `process force disable survives settings override`() { + KeychainAccessGate.resetOverrideForTesting() + defer { KeychainAccessGate.resetOverrideForTesting() } + + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + KeychainAccessGate.isDisabled = false + + #expect(KeychainAccessGate.isDisabled) + #expect(KeychainAccessGate.processDisableReason == "unbundled-executable") + } + @Test func `clear denied allows immediate retry`() { KeychainAccessGate.withTaskOverrideForTesting(false) { diff --git a/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift new file mode 100644 index 000000000..e68ba10ef --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift @@ -0,0 +1,157 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeWebRecoveryMenuTests { + private func makeSettings() -> SettingsStore { + let suite = "ClaudeWebRecoveryMenuTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func actions( + error: String, + source: ClaudeUsageDataSource, + cookieSource: ProviderCookieSource = .auto, + selectedSessionKey: Bool = false, + attempts: [ProviderFetchAttempt] = []) -> [(String, MenuDescriptor.MenuAction)] + { + let settings = self.makeSettings() + settings.claudeUsageDataSource = source + if selectedSessionKey { + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + } + settings.claudeCookieSource = cookieSource + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.errors[.claude] = error + store.lastFetchAttempts[.claude] = attempts + + return MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + } + + @Test + func `web session errors show claude relogin action`() { + let errors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + + for error in errors { + let actions = self.actions(error: error, source: .web) + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + } + + @Test + func `auto source shows relogin action for terminal web session error`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .auto) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `non-web source does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .oauth) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `manual cookies do not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .manual) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `selected session account does not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .auto, + selectedSessionKey: true) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unavailable web strategy shows relogin action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .web, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.web", + kind: .web, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `generic unavailable error without web attempt keeps account action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .auto, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.cli", + kind: .cli, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unrelated web error does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.serverError(statusCode: 500).localizedDescription, + source: .web) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } +} diff --git a/Tests/CodexBarTests/ClickToCopyOverlayTests.swift b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift new file mode 100644 index 000000000..449e3ad6b --- /dev/null +++ b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift @@ -0,0 +1,50 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct ClickToCopyOverlayTests { + @Test + func `view stores the latest copyText`() { + let view = ClickToCopyView(copyText: "original") + #expect(view.copyText == "original") + view.copyText = "updated" + #expect(view.copyText == "updated") + } + + @Test + func `pasteboard copy waits for deferred scheduler`() { + var pendingAction: (() -> Void)? + var copiedText: String? + var completed = false + + MenuPasteboardCopy.perform( + "copy me", + scheduler: { pendingAction = $0 }, + writer: { copiedText = $0 }, + completion: { completed = true }) + + #expect(copiedText == nil) + #expect(!completed) + pendingAction?() + #expect(copiedText == "copy me") + #expect(completed) + } + + @Test + func `mouseDown forwards the latest copyText`() { + var copiedText: String? + let view = ClickToCopyView(copyText: "original") { copiedText = $0 } + view.copyText = "updated" + + view.mouseDown(with: NSEvent()) + + #expect(copiedText == "updated") + } + + @Test + func `accepts first mouse so error text overlay is clickable on first focus`() { + let view = ClickToCopyView(copyText: "x") + #expect(view.acceptsFirstMouse(for: nil) == true) + } +} diff --git a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift new file mode 100644 index 000000000..5941c8b00 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift @@ -0,0 +1,346 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CodexAccountMenuDisplaySnapshotTests { + private func makeSettings() -> SettingsStore { + let suite = "CodexAccountMenuDisplaySnapshotTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func liveSnapshot(email: String) -> CodexAccountReconciliationSnapshot { + CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: ObservedSystemCodexAccount( + email: email, + codexHomePath: "/tmp/\(email)", + observedAt: Date()), + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + } + + private func cachedProjection( + snapshot: CodexAccountReconciliationSnapshot, + loadedAt: Date = Date(timeIntervalSinceNow: -3600)) -> CachedCodexAccountMenuProjection + { + CachedCodexAccountMenuProjection( + activeSource: snapshot.activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } + + @Test + func `cold menu projection read never loads auth state`() async { + let settings = self.makeSettings() + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "loaded@example.com")) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + #expect(settings.codexVisibleAccountProjectionForMenuDisplay == nil) + #expect(probe.callCount == 0) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "loaded@example.com") + } + + @Test + func `override snapshot load preserves persisted account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot) + + let otherID = UUID() + let otherAccount = ManagedCodexAccount( + id: otherID, + email: "other@example.com", + managedHomePath: "/tmp/other", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let overrideSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [otherAccount], + activeStoredAccount: otherAccount, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: otherID), + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in overrideSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + _ = settings.codexAccountReconciliationSnapshot(activeSourceOverride: .managedAccount(id: otherID)) + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "active@example.com") + } + + @Test + func `managed account change refreshes account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot, loadedAt: Date()) + + let addedAccount = ManagedCodexAccount( + id: UUID(), + email: "added@example.com", + managedHomePath: "/tmp/added", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let refreshedSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [addedAccount], + activeStoredAccount: nil, + liveSystemAccount: activeSnapshot.liveSystemAccount, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in refreshedSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.contains { + $0.email == "added@example.com" + } == true) + } + + @Test + func `stale menu projection returns immediately then refreshes concurrently`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "after@example.com")) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + #expect(probe.callCount == 0) + #expect(settings.codexAccountMenuProjectionNeedsRevalidation) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "after@example.com") + } + + @Test + func `revalidation discards result after reconciliation generation changes`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "discarded@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let task = Task { await settings.revalidateCodexAccountMenuProjection() } + await probe.waitUntilCalled() + settings.invalidateCodexAccountReconciliationSnapshotCache() + probe.release() + + #expect(await task.value == .discarded) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + } + + @Test + func `fresh menu open coalesces account projection revalidation and identity stays read only`() async throws { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + StatusItemController.setCodexAccountMenuProjectionRevalidationEnabledForTesting(true) + defer { + StatusItemController.resetCodexAccountMenuProjectionRevalidationEnabledForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "after@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let menu = NSMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.markMenuFresh(menu) + #expect(controller.codexAccountMenuDisplay(for: .codex) == nil) + #expect(probe.callCount == 0) + + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + let revalidation = try #require(controller.codexAccountMenuProjectionRevalidationTask) + controller.menuWillOpen(menu) + await probe.waitUntilCalled() + + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + probe.release() + await revalidation.value + + #expect(controller.codexAccountMenuProjectionRevalidationTask == nil) + #expect(controller.menuContentVersion == versionBeforeOpen + 1) + } + + @Test + func `selecting displayed account uses captured source without reconciliation`() throws { + let settings = self.makeSettings() + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: "/tmp/first", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let second = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + managedHomePath: "/tmp/second", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [first, second], + activeStoredAccount: first, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: firstID), + hasUnreadableAddedAccountStore: false) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let displayedAccount = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: secondID) + }) + let probe = CodexAccountSnapshotLoaderProbe(snapshot: snapshot) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: snapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.selectDisplayedCodexVisibleAccount(displayedAccount) + + #expect(probe.callCount == 0) + #expect(settings.codexActiveSource == .managedAccount(id: secondID)) + #expect(settings.cachedCodexAccountMenuProjection == nil) + } +} + +private final class CodexAccountSnapshotLoaderProbe: @unchecked Sendable { + private let lock = NSLock() + private let snapshot: CodexAccountReconciliationSnapshot + private let blocks: Bool + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var _callCount = 0 + private var _loadedOffMainThread = false + private var released = false + + init(snapshot: CodexAccountReconciliationSnapshot, blocks: Bool = false) { + self.snapshot = snapshot + self.blocks = blocks + } + + var callCount: Int { + self.lock.withLock { self._callCount } + } + + var loadedOffMainThread: Bool { + self.lock.withLock { self._loadedOffMainThread } + } + + func load() -> CodexAccountReconciliationSnapshot { + self.lock.withLock { + self._callCount += 1 + self._loadedOffMainThread = self._loadedOffMainThread || !Thread.isMainThread + } + if self.blocks { + self.releaseSemaphore.wait() + } + return self.snapshot + } + + func waitUntilCalled() async { + while self.callCount == 0 { + await Task.yield() + } + } + + func release() { + let shouldSignal = self.lock.withLock { + guard !self.released else { return false } + self.released = true + return true + } + if shouldSignal { + self.releaseSemaphore.signal() + } + } +} diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index c5083bec1..3c2865027 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -67,6 +67,40 @@ struct CopilotUsageFetcherTests { #expect(snapshot.identity?.loginMethod == "Business") } + @Test + func `fetch keeps explicitly unlimited chat quota`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token gh-token") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "gh-token", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 0) + #expect(snapshot.identity?.loginMethod == "Individual") + } + @Test func `makeRateWindow drops business token billing placeholder quota`() { // entitlement=0/remaining=0/percent_remaining=100 must not become a "0% used" diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 48f99b98f..4d42656c7 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -476,6 +476,59 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat == nil) } + @Test + func `keeps unlimited chat fallback quota without percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 200, + "remaining": 191, + "percent_remaining": 95.5, + "quota_id": "premium_interactions" + }, + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """) + + #expect(response.quotaSnapshots.premiumInteractions?.quotaId == "premium_interactions") + #expect(response.quotaSnapshots.chat?.quotaId == "chat_messages") + #expect(response.quotaSnapshots.chat?.unlimited == true) + #expect(response.quotaSnapshots.chat?.usedPercent == 0) + } + + @Test + func `unlimited quota overrides placeholder percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 0, + "quota_id": "chat", + "unlimited": true + } + } + } + """) + + let chat = try #require(response.quotaSnapshots.chat) + #expect(chat.percentRemaining == 100) + #expect(chat.usedPercent == 0) + #expect(!chat.isPlaceholder) + } + @Test func `flags zero entitlement snapshot as placeholder`() { let snapshot = CopilotUsageResponse.QuotaSnapshot( diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index f1c98f03a..b157faea7 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -1,5 +1,6 @@ import Testing @testable import CodexBar +@testable import CodexBarCore struct CostHistoryChartMenuViewTests { @Test @@ -9,4 +10,36 @@ struct CostHistoryChartMenuViewTests { #expect(CostHistoryChartMenuView.windowLabel(days: 7) == "Last 7 days") #expect(CostHistoryChartMenuView.windowLabel(days: 30) == "Last 30 days") } + + @Test + @MainActor + func `model breakdown keeps every item behind a bounded scrolling viewport`() { + let breakdown = (1...6).map { index in + CostUsageDailyReport.ModelBreakdown( + modelName: "model-\(index)", + costUSD: Double(index), + totalTokens: index * 100) + } + + let ordered = CostHistoryChartMenuView.orderedBreakdownItems(breakdown) + + #expect(ordered.map(\.modelName) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(ordered.count == 6) + #expect(CostHistoryChartMenuView.detailViewportRowCount(itemCount: ordered.count) == 4) + #expect(CostHistoryChartMenuView.detailRowsNeedScrolling(itemCount: ordered.count)) + } + + @Test + @MainActor + func `short model breakdown does not scroll or reserve extra rows`() { + #expect(CostHistoryChartMenuView.detailViewportRowCount(itemCount: 3) == 3) + #expect(CostHistoryChartMenuView.detailRowsNeedScrolling(itemCount: 3) == false) + } } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 3cfe53521..52a787e89 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -4,7 +4,7 @@ import Testing struct CostUsageCacheTests { @Test - func `cache file URL uses provider-specific artifact version`() { + func `cache file URL uses provider artifact versions`() { let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) @@ -13,11 +13,11 @@ struct CostUsageCacheTests { // Codex: upstream bumped to 8 in v0.27.0 (further pricing/parser // changes: JSONL shape benchmark + per-event token usage). Claude - // /Vertex: fork's 2 → 3 bump (0.23.1 era, gained claude-opus-4-7 - // + fallback resolver) retained — upstream still at 2. + // /Vertex: upstream bumped to 4 with the 0.33 Claude pricing + // correction, superseding fork's prior 2 → 3 history. #expect(codexURL.lastPathComponent == "codex-v8.json") - #expect(claudeURL.lastPathComponent == "claude-v3.json") - #expect(vertexURL.lastPathComponent == "vertexai-v3.json") + #expect(claudeURL.lastPathComponent == "claude-v4.json") + #expect(vertexURL.lastPathComponent == "vertexai-v4.json") } // MARK: - Pricing fingerprint mechanism @@ -239,6 +239,26 @@ struct CostUsageCacheTests { #expect(loaded.days.isEmpty) } + @Test + func `current codex cache accepts parser compatible 0_33 producer`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p3c27f997569eb3c5") + + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 123) + #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) + } + @Test func `non codex cache does not require producer key`() throws { let root = try self.makeTemporaryCacheRoot() diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index a9a90d2ac..b1d5e14b7 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -376,6 +376,153 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `claude cost supports fable5 bundled fallback`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 1.25e-5) + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost preserves historical sonnet46 long context pricing`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let historical = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_359_999), + modelsDevCacheRoot: emptyCacheRoot) + let current = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: emptyCacheRoot) + + #expect(historical == 1.44) + #expect(current == 0.72) + } + + @Test + func `claude cost ignores stale sonnet46 threshold catalog after cutover`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: cacheRoot) + + #expect(cost == 0.72) + } + + @Test + func `claude cost prices one hour cache writes separately`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 30, + cacheCreationInputTokens1h: 20, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + + (20.0 * 1e-6) + + (10.0 * 1.25e-5) + + (20.0 * 2e-5) + + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost applies long context rates across cache write durations`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-threshold-model": { + "id": "claude-threshold-model", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-threshold-model", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + cacheCreationInputTokens1h: 120_000, + outputTokens: 0, + modelsDevCacheRoot: cacheRoot) + let expected = (120_000.0 * 12e-6) + + (120_000.0 * 7.5e-6) + #expect(cost == expected) + } + + @Test + func `claude sonnet46 uses standard pricing across full context`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + outputTokens: 0, + modelsDevCacheRoot: emptyCacheRoot) + #expect(cost == 240_000.0 * 3.75e-6) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( @@ -422,11 +569,10 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - let expected = (200_000.0 * 3e-6) - + (10.0 * 6e-6) - + (5.0 * 0.3e-6) - + (5.0 * 3.75e-6) - + (5.0 * 15e-6) + let expected = (200_010.0 * 6e-6) + + (5.0 * 0.6e-6) + + (5.0 * 7.5e-6) + + (5.0 * 22.5e-6) #expect(cost == expected) } diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift new file mode 100644 index 000000000..8ea5a03b4 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScanExecutorTests { + @Test + func `runs work on the dedicated scan queue and returns its value`() async throws { + let queue = self.makeQueue() + let label = try await CostUsageScanExecutor.run(on: queue) { _ in + String(cString: __dispatch_queue_get_label(nil)) + } + #expect(label == queue.label) + } + + @Test + func `propagates thrown errors`() async { + struct ScanFailure: Error {} + let queue = self.makeQueue() + await #expect(throws: ScanFailure.self) { + try await CostUsageScanExecutor.run(on: queue) { _ -> Int in + throw ScanFailure() + } + } + } + + @Test + func `serializes overlapping scans`() async throws { + let queue = self.makeQueue() + let state = LockedValue((active: 0, maxActive: 0)) + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<4 { + group.addTask { + try await CostUsageScanExecutor.run(on: queue) { _ in + state.update { + $0.active += 1 + $0.maxActive = max($0.maxActive, $0.active) + } + Thread.sleep(forTimeInterval: 0.02) + state.update { $0.active -= 1 } + } + } + } + try await group.waitForAll() + } + #expect(state.read { $0.maxActive } == 1) + } + + @Test + func `cancellation reaches in-flight work through checkCancellation`() async { + let queue = self.makeQueue() + let workStarted = LockedValue(false) + let task = Task { + try await CostUsageScanExecutor.run(on: queue) { checkCancellation in + workStarted.set(true) + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + #expect(await self.waitUntil { workStarted.value }) + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `work cancelled while queued resumes with CancellationError`() async { + let queue = self.makeQueue() + let blockerStarted = LockedValue(false) + let releaseBlocker = LockedValue(false) + let blocker = Task { + try await CostUsageScanExecutor.run(on: queue) { _ in + blockerStarted.set(true) + while !releaseBlocker.value { + Thread.sleep(forTimeInterval: 0.002) + } + } + } + #expect(await self.waitUntil { blockerStarted.value }) + + let queuedWorkStarted = LockedValue(false) + let queued = Task { + try await CostUsageScanExecutor.run(on: queue) { _ in + queuedWorkStarted.set(true) + Issue.record("queued work should not run after cancellation") + } + } + try? await Task.sleep(for: .milliseconds(50)) + + let cancellationObserved = LockedValue(nil) + let observer = Task { + do { + try await queued.value + cancellationObserved.set(false) + } catch is CancellationError { + cancellationObserved.set(true) + } catch { + cancellationObserved.set(false) + } + } + queued.cancel() + + #expect(await self.waitUntil { cancellationObserved.value != nil }) + #expect(cancellationObserved.value == true) + #expect(!queuedWorkStarted.value) + #expect(!releaseBlocker.value) + + releaseBlocker.set(true) + await observer.value + _ = try? await blocker.value + } + + private func makeQueue() -> DispatchQueue { + DispatchQueue(label: "\(CostUsageScanExecutor.queueLabel).tests.\(UUID().uuidString)") + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () -> Bool) async -> Bool + { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return condition() + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + var value: Value { + self.lock.withLock { self.storage } + } + + func read(_ body: (Value) -> Result) -> Result { + self.lock.withLock { body(self.storage) } + } + + func set(_ value: Value) { + self.lock.withLock { self.storage = value } + } + + func update(_ body: (inout Value) -> Void) { + self.lock.withLock { body(&self.storage) } + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift new file mode 100644 index 000000000..0d8beb4b7 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -0,0 +1,457 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScannerClaudeFableTests { + @Test + func `claude fable 5 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].model == "claude-fable-5") + let expected = 0.001395 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude transcript refusal remains priced without billing provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-refusal.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_refusal", + "type": "message", + "role": "assistant", + "stop_reason": "refusal", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 0, + ], + ], + "requestId": "req_fable_5_refusal", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_refusal", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].input == 100) + #expect(parsed.rows[0].cacheCreate == 10) + #expect(parsed.rows[0].cacheRead == 20) + #expect(parsed.rows[0].output == 0) + let expected = 0.001145 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + #expect(parsed.rows[0].costPriced == true) + } + + @Test + func `claude fable 5 prices one hour cache creation tokens`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_cache_ttl", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].cacheCreate == 30) + #expect(parsed.rows[0].cacheCreate1h == 20) + let expected = 0.001795 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude cached rows preserve one hour writes for deferred pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-custom-cache-model", + "id": "msg_custom_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_custom_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_custom_cache_ttl", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + let cached = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(cached.days["2026-06-09"]?["claude-custom-cache-model"]?[safe: 7] == 20) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: "claude-custom-cache-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let expected = 0.001795 + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - expected) < 0.000000001) + } + + @Test + func `claude deferred pricing preserves request long context boundaries`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-threshold.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_1", + requestID: "req_custom_threshold_1", + inputTokens: 150_000), + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_2", + requestID: "req_custom_threshold_2", + inputTokens: 150_000), + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: "claude-custom-threshold-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 3) < 0.000000001) + } + + @Test + func `claude cached rows reprice after models dev catalog changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let model = "claude-custom-repricing-model" + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-repricing.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: model, + messageID: "msg_custom_repricing", + requestID: "req_custom_repricing", + inputTokens: 240_000), + ])) + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: model), + fetchedAt: day, + cacheRoot: env.cacheRoot) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let premium = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((premium.summary?.totalCostUSD ?? 0) - 4.8) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: model), + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 2.4) < 0.000000001) + } + + @Test + func `claude cached historical rows keep original tariff`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 12) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/sonnet-46-historical.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-sonnet-4-6", + "id": "msg_sonnet_46_historical", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 240_000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": "req_sonnet_46_historical", + "type": "assistant", + "timestamp": "2026-03-12T12:00:00.000Z", + "sessionId": "session_sonnet_46_historical", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let initial = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((initial.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicSonnet46StandardCatalog(), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let cached = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((cached.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + } + + private static func anthropicModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicThresholdModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5, + "context_over_200k": { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25 + } + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicSonnet46StandardCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func claudeUsageEvent( + model: String, + messageID: String, + requestID: String, + inputTokens: Int) -> [String: Any] + { + [ + "message": [ + "model": model, + "id": messageID, + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": inputTokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": requestID, + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_\(requestID)", + ] + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 336f3ae3e..7fb3a5c6f 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -4,6 +4,46 @@ import Testing // swiftlint:disable:next type_body_length struct CostUsageScannerTests { + @Test + func `codex file metadata detects append truncation and replacement`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fileURL = root.appendingPathComponent("session.jsonl") + try Data("abc".utf8).write(to: fileURL) + + let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(initial.size == 3) + #expect(initial.fileId != nil) + let linkURL = root.appendingPathComponent("linked-session.jsonl") + try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL) + let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL) + #expect(linked.size == initial.size) + #expect(linked.fileId == initial.fileId) + + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("def".utf8)) + try handle.close() + let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(appended.size == 6) + #expect(appended.fileId == initial.fileId) + + let truncateHandle = try FileHandle(forWritingTo: fileURL) + try truncateHandle.truncate(atOffset: 2) + try truncateHandle.close() + let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(truncated.size == 2) + #expect(truncated.fileId == initial.fileId) + + try FileManager.default.removeItem(at: fileURL) + try Data("replacement".utf8).write(to: fileURL) + let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(replaced.size == 11) + #expect(replaced.fileId != initial.fileId) + } + @Test func `vertex daily report filters claude logs`() throws { let env = try CostUsageTestEnvironment() @@ -159,7 +199,7 @@ struct CostUsageScannerTests { let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) let first = env.isoString(for: day) let second = env.isoString(for: day.addingTimeInterval(1)) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstEntry: [String: Any] = [ "type": "assistant", "timestamp": first, diff --git a/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift new file mode 100644 index 000000000..3ee0cf47e --- /dev/null +++ b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import CodexBarCore + +struct CursorLegacyRequestProjectionTests { + @Test + func `legacy plan hides token-based auto and api bars`() { + let snapshot = Self.snapshot(requestsUsed: 347, requestsLimit: 500) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(abs((usageSnapshot.primary?.usedPercent ?? 0) - 69.4) < 0.01) + #expect(usageSnapshot.cursorRequests?.used == 347) + #expect(usageSnapshot.cursorRequests?.limit == 500) + #expect(usageSnapshot.secondary == nil) + #expect(usageSnapshot.tertiary == nil) + } + + @Test + func `unusable legacy request quota preserves token bars`() { + let requestCases: [(used: Int?, limit: Int?)] = [ + (nil, 500), + (12, 0), + ] + + for requestCase in requestCases { + let usageSnapshot = Self.snapshot( + requestsUsed: requestCase.used, + requestsLimit: requestCase.limit).toUsageSnapshot() + + #expect(usageSnapshot.primary?.usedPercent == 7.0) + #expect(usageSnapshot.cursorRequests == nil) + #expect(usageSnapshot.secondary?.usedPercent == 11.0) + #expect(usageSnapshot.tertiary?.usedPercent == 22.0) + } + } + + private static func snapshot( + requestsUsed: Int?, + requestsLimit: Int?) -> CursorStatusSnapshot + { + CursorStatusSnapshot( + planPercentUsed: 7.0, + autoPercentUsed: 11.0, + apiPercentUsed: 22.0, + planUsedUSD: 1.4, + planLimitUSD: 20.0, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: "user@example.com", + accountName: nil, + rawJSON: nil, + requestsUsed: requestsUsed, + requestsLimit: requestsLimit) + } +} diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift index 7ac845b4a..775648416 100644 --- a/Tests/CodexBarTests/CursorMenuCardModelTests.swift +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -46,4 +46,48 @@ struct CursorMenuCardModelTests { #expect(metric.paceOnTop == false) } } + + @Test + func `legacy request plan shows single requests bar with count`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + // A legacy snapshot, as produced by CursorStatusSnapshot.toUsageSnapshot(): only the request + // window survives, Auto/API are dropped, and the request count rides along. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 69.4, + windowMinutes: cycleMinutes, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + tertiary: nil, + cursorRequests: CursorRequestUsage(used: 347, limit: 500), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Requests"]) + #expect(model.metrics.first?.detailText == "Request quota: 347 / 500") + } } diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift new file mode 100644 index 000000000..7590df20c --- /dev/null +++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift @@ -0,0 +1,392 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct DevinUsageFetcherTests { + private static let now = Date(timeIntervalSince1970: 1_780_000_000) + + @Test + func `parses quota usage response into daily and weekly windows`() throws { + let response: [String: Any] = [ + "plan_name": "pro", + "quota_usage": [ + "daily_quota": [ + "used": 3, + "limit": 10, + "reset_at": "2026-06-01T08:00:00Z", + ], + "weekly_quota": [ + "remaining_percent": 0.25, + "next_reset_at": 1_780_560_000, + ], + ], + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 30) + #expect(snapshot.weekly?.usedPercent == 75) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_780_300_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_780_560_000) + #expect(snapshot.planName == "Pro") + #expect(snapshot.organization == "example-org") + } + + @Test + func `parses current Devin quota response with reset timestamps`() throws { + let response: [String: Any] = [ + "is_quota_plan": true, + "has_quota_allocation": true, + "daily_percentage": 0.12, + "weekly_percentage": 42, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": false, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 12) + #expect(snapshot.weekly?.usedPercent == 42) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_781_164_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_781_424_000) + } + + @Test + func `keeps weekly quota when current plan hides daily quota`() throws { + let response: [String: Any] = [ + "weekly_percentage": 25, + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": true, + ] + + let usage = try DevinUsageParser.parse(response, organization: nil, now: Self.now).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 25) + } + + @Test + func `parses zero percentages from JSON response`() throws { + let data = Data(""" + { + "daily_percentage": 0, + "weekly_percentage": 0, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00" + } + """.utf8) + + let snapshot = try DevinUsageParser.parse(data, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `usage snapshot maps Devin quotas to primary and secondary windows`() { + let snapshot = DevinUsageSnapshot( + daily: DevinQuotaWindow(usedPercent: 12), + weekly: DevinQuotaWindow(usedPercent: 42), + planName: "Free", + organization: "example-org", + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "Daily") + #expect(usage.secondary?.usedPercent == 42) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetDescription == "Weekly") + #expect(usage.identity?.providerID == .devin) + #expect(usage.identity?.accountOrganization == "example-org") + #expect(usage.identity?.loginMethod == "Free") + } + + @Test + func `fetch sends bearer token and organization header`() async throws { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "app.devin.ai") + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret-token") + #expect(request.value(forHTTPHeaderField: "x-cog-org-id") == "org_GQ6LhcfkW1TSinM6") + let body = """ + {"daily":{"used_percent":10},"weekly":{"used_percent":20},"plan":"free"} + """ + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let snapshot = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + + #expect(snapshot.daily?.usedPercent == 10) + #expect(snapshot.weekly?.usedPercent == 20) + #expect(snapshot.planName == "Free") + } + + @Test + func `fetch does not mask parser failure with fallback endpoint errors`() async { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage" ? 200 : 404, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + Issue.record("Expected quota parsing to fail") + } catch let error as DevinUsageError { + guard case .parseFailed = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + } catch { + Issue.record("Expected DevinUsageError, got \(error)") + } + + #expect(await stub.requests().count == 1) + } + + @Test + func `normalizes organization inputs`() { + #expect(DevinUsageFetcher.normalizedOrganization("example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org/example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org_GQ6LhcfkW1TSinM6") == + "organizations/org_GQ6LhcfkW1TSinM6") + #expect(DevinUsageFetcher.normalizedOrganization("org-b31f951cd01d4c6da84991cf5b970cfb") == + "organizations/org-b31f951cd01d4c6da84991cf5b970cfb") + #expect(DevinUsageFetcher.normalizedOrganization("https://app.devin.ai/org/example-org/settings/usage") == + "org/example-org") + } + + @Test + func `manual auth strips Authorization and Bearer prefixes`() throws { + let auth = try #require(DevinUsageFetcher.manualAuth( + from: "Authorization: Bearer secret-token", + organization: "example-org")) + + #expect(auth.bearerToken == "secret-token") + #expect(auth.organization == "org/example-org") + #expect(auth.sourceLabel == "manual") + } + + #if os(macOS) + @Test + func `empty app organization setting preserves imported organization`() async throws { + defer { DevinSessionImporter.importSessionOverrideForTesting = nil } + DevinSessionImporter.importSessionOverrideForTesting = { _, organizationOverride, _ in + #expect(organizationOverride == nil) + return DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Default") + } + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(#"{"daily_percentage":0,"weekly_percentage":0}"#.utf8), response) + } + + let snapshot = try await DevinUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)).fetch( + organizationOverride: "", + now: Self.now, + transport: stub) + + #expect(snapshot.organization == "example-org") + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `session importer extracts current auth1 token and matching org`() throws { + let accessToken = "auth1_abcdefghijklmnopqrstuvwxyz0123456789" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}auth1_session": + #"{"token":"\#(accessToken)","userId":"github|123"}"#, + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-example-org": + "\"org_GQ6LhcfkW1TSinM6\"", + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: "example-org", + sourceLabel: "Chrome Default")) + + #expect(session.accessToken == accessToken) + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + #expect(session.sourceLabel == "Chrome Default") + } + + @Test + func `session importer infers organization from post auth storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}post-auth-v3-null-github|123-org_name-example-org": """ + { + "externalOrgId": null, + "userId": "github|123", + "internalOrgId": "org_GQ6LhcfkW1TSinM6", + "orgName": "example-org" + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer infers organization from member info storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + { + "value": { + "org_id": "org_GQ6LhcfkW1TSinM6", + "org_name": "example-org" + } + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer falls back to internal organization id`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + {"value":{"org_id":"org_GQ6LhcfkW1TSinM6"}} + """, + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ignores org words inside storage key names`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-null": "\"null\"", + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer deduplicates repeated browser tokens using richest organization metadata`() { + let sessions = [ + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default"), + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1"), + ] + + let deduplicated = DevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 1) + #expect(deduplicated.first?.sourceLabel == "Chrome Profile 1") + #expect(deduplicated.first?.organization == "org/example") + #expect(deduplicated.first?.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ranks organization aware profiles first`() { + let incomplete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_incomplete", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default") + let complete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_complete", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1") + + let ranked = DevinSessionImporter.rankSessions([incomplete, complete]) + + #expect(ranked.map(\.sourceLabel) == ["Chrome Profile 1", "Chrome Default"]) + } + + @Test + func `missing organization retries the next browser profile`() { + #expect(DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.missingOrganization)) + #expect(!DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.parseFailed("invalid response"))) + } + + @Test + func `automatic local storage import does not fall back beyond Chrome`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: temp) } + + let braveRoot = temp + .appendingPathComponent("Library/Application Support/BraveSoftware/Brave-Browser/Default") + try FileManager.default.createDirectory(at: braveRoot, withIntermediateDirectories: true) + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + + #expect(detection.hasUsableProfileData(.brave)) + #expect(!detection.hasUsableProfileData(.chrome)) + #expect(DevinSessionImporter.localStorageBrowsers(browserDetection: detection).isEmpty) + } + #endif +} diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift new file mode 100644 index 000000000..c0a66bd43 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DoubaoUsageSnapshotTests { + @Test + func `normal usage with both headers present and non-empty reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 750, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "250/1000 requests") + } + + @Test + func `boundary normal usage at near-full reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 1, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 99.9) + #expect(usage.primary?.resetDescription == "999/1000 requests") + } + + @Test + func `unreliable headers limit positive remaining zero falls back to Active hint`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true, + requestLimitsReliable: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `explicit rate limit with zero remaining reports exhausted quota`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + } + + @Test + func `both headers missing but key valid falls back to Active hint`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `invalid key with no headers reports No usage data`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "No usage data") + } + + @Test + func `provider identity is correctly tagged as doubao`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 500, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.accountEmail == nil) + } +} + +struct DoubaoUsageFetcherTests { + @Test + func `repeated successful zero remaining responses use active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 200, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 2) + } + + @Test + func `successful final request followed by rate limit reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `headerless rate limit confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `rate limit with request limit header reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: 1000, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 1) + } + + @Test + func `bare rate limit uses active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 1) + } + + @Test + func `failed zero remaining confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.timedOut)), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `task cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } + #expect(await transport.requestCount() == 2) + } + + @Test + func `url cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.cancelled)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } throws: { error in + (error as? URLError)?.code == .cancelled + } + #expect(await transport.requestCount() == 2) + } +} + +private actor DoubaoScriptedTransport: ProviderHTTPTransport { + enum Result { + case response(statusCode: Int, limit: Int?, remaining: Int?) + case failure(URLError) + case cancellation + } + + private var results: [Result] + private var requests = 0 + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests += 1 + let result = self.results.removeFirst() + switch result { + case let .response(statusCode, limit, remaining): + var headers: [String: String] = [:] + if let limit { + headers["x-ratelimit-limit-requests"] = String(limit) + } + if let remaining { + headers["x-ratelimit-remaining-requests"] = String(remaining) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers)! + return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .failure(error): + throw error + case .cancellation: + throw CancellationError() + } + } +} diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift index 926612729..1d51d3dfa 100644 --- a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift @@ -1,4 +1,5 @@ import Foundation +import os import Testing @testable import CodexBar @@ -41,4 +42,29 @@ struct GoogleWorkspaceStatusNetworkTests { #expect(requests.count == 1) #expect(requests.first?.url?.host == "www.google.com") } + + @Test + func `fetchWorkspaceStatus decodes off the main thread when called from the main actor`() async throws { + // The incidents feed can run to hundreds of kilobytes; decoding it on the main + // actor stalls the UI for 150-340ms per Google-status provider per refresh (#1399). + let decodedOffMainThread = OSAllocatedUnfairLock(initialState: false) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data("[]".utf8), response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport, + beforeDecoding: { + decodedOffMainThread.withLock { $0 = !Thread.isMainThread } + }) + + #expect(status.indicator == .none) + #expect(decodedOffMainThread.withLock { $0 }) + } } diff --git a/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift new file mode 100644 index 000000000..215efc1a5 --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import CodexBar + +struct KeychainPromptCoordinatorTests { + @Test + func `detects raw SwiftPM debug executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/debug/CodexBar")) + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBar")) + } + + @Test + func `detects raw SwiftPM release executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/release/CodexBar")) + } + + @Test + func `detects custom SwiftPM scratch path`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/tmp/codexbar-build/arm64-apple-macosx/debug/CodexBar")) + } + + @Test + func `keeps packaged app keychain behavior`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Applications/CodexBar.app/Contents/MacOS/CodexBar")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/package/CodexBar.app/Contents/MacOS/CodexBar")) + } + + @Test + func `ignores unrelated executable paths`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBarCLI")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("CodexBar")) + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 32eeff2be..a2ffe874e 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -16,6 +16,7 @@ struct LocalizationLanguageCatalogTests { "language_dutch", "language_ukrainian", "language_vietnamese", + "language_japanese", ] @Test @@ -68,4 +69,23 @@ struct LocalizationLanguageCatalogTests { #expect(contents.contains(key), "Missing localization key: \(key)") } } + + @Test + func `japanese usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let jaURL = root.appendingPathComponent("Sources/CodexBar/Resources/ja.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: jaURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ja_JP"), + arguments: [7, 3]) + + #expect(rendered.contains("7日間")) + #expect(rendered.contains("3サービス")) + } } diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index d0e769550..097f13093 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -56,6 +56,41 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.resetDescription == "Gemini Pro") } + @Test + func `automatic metric ignores untracked antigravity family lane`() throws { + let untrackedReset = Date(timeIntervalSince1970: 1000) + let exhaustedReset = Date(timeIntervalSince1970: 2000) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6", + modelId: "claude-sonnet-4-6", + remainingFraction: nil, + resetTime: untrackedReset, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro", + modelId: "gemini-3-1-pro", + remainingFraction: 0, + resetTime: exhaustedReset, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + #expect(snapshot.primary == nil) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetsAt == exhaustedReset) + } + @Test func `explicit antigravity metric keeps requested family lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index f2947a612..b725acf02 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -57,7 +57,7 @@ struct MenuCardAntigravityTests { } @Test - func `antigravity zero percent metric still shows reset text`() throws { + func `antigravity untracked metric stays out of family summary`() throws { let now = Date(timeIntervalSince1970: 1_735_000_000) let resetTime = now.addingTimeInterval(3600) let antigravitySnapshot = AntigravityStatusSnapshot( @@ -108,7 +108,7 @@ struct MenuCardAntigravityTests { #expect(model.metrics[1].percent == 0) #expect(model.metrics[1].percentLabel == "0% left") - #expect(model.metrics[1].resetText != nil) + #expect(model.metrics[1].resetText == nil) } @Test diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift new file mode 100644 index 000000000..a1c0817e2 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -0,0 +1,245 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardModelCodexDegradedQuotaTests { + @Test + func `codex local token usage keeps remote quota unavailable error visible`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [ + .init( + date: "2026-06-05", + inputTokens: 710_217, + outputTokens: 11749, + totalTokens: 721_966, + costUSD: 1.081155, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex usage is temporarily unavailable. Try refreshing.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.usesStackedDetailLayout) + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) + #expect(model.tokenUsage?.monthLine.contains("$583.13") == true) + #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + } + + @Test + func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = "Codex usage is temporarily unavailable. Try refreshing." + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage == nil) + } + + @Test + func `codex local token usage preserves limits unavailable placeholder`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves sign-in guidance`() throws { + let model = try self.makeModel( + tokenCostUsageEnabled: true, + lastError: "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText.contains("codex login")) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves mapped transport error`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = try #require(CodexUIErrorMapper.userFacingMessage("Codex connection failed: timed out.")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + } + + @Test + func `credits select stacked detail layout without quota metrics`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: nil, + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: "$12.34 remaining", + creditsRemaining: 12.34, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: "No usage yet", + progressColor: .blue) + + #expect(model.usesStackedDetailLayout) + } + + private func makeModel( + tokenCostUsageEnabled: Bool, + lastError: String?) throws -> UsageMenuCardView.Model + { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + return UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: lastError, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: tokenCostUsageEnabled, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/MenuCardSubtitleTests.swift b/Tests/CodexBarTests/MenuCardSubtitleTests.swift index 8407c1c10..6cda8e0d5 100644 --- a/Tests/CodexBarTests/MenuCardSubtitleTests.swift +++ b/Tests/CodexBarTests/MenuCardSubtitleTests.swift @@ -46,4 +46,49 @@ struct MenuCardSubtitleTests { #expect(model.subtitleText == UsageFormatter.updatedString(from: updatedAt, now: now)) } + + @Test + func `subtitle shows refreshing while cached snapshot remains visible`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == "Refreshing…") + #expect(model.subtitleStyle == .loading) + #expect(!model.metrics.isEmpty) + } } diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift new file mode 100644 index 000000000..5d48efd3e --- /dev/null +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -0,0 +1,517 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting { + private(set) var isHighlighted = false + + func setHighlighted(_ highlighted: Bool) { + self.isHighlighted = highlighted + } +} + +extension StatusMenuTests { + private func makeRecyclingController(settings: SettingsStore) -> StatusItemController { + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } + + private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] { + var identities: [String: ObjectIdentifier] = [:] + for item in menu.items { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + identities[id] = ObjectIdentifier(view) + } + return identities + } + + @Test + func `merged menu width uses widest provider action set`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let narrow = [ + MenuDescriptor.Section(entries: [ + .action("Usage Dashboard", .dashboard), + ]), + ] + let wide = [ + MenuDescriptor.Section(entries: [ + .action(String(repeating: "W", count: 60), .dashboard), + ]), + ] + + let narrowWidth = controller.measuredMenuCardWidth(for: [narrow]) + let stableWidth = controller.measuredMenuCardWidth(for: [narrow, wide]) + + #expect(narrowWidth == StatusItemController.menuCardBaseWidth) + #expect(stableWidth > narrowWidth) + #expect(controller.measuredMenuCardWidth(for: [wide, narrow]) == stableWidth) + } + + @Test + func `menu width normalization includes usage history submenu row`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let usageHistoryItem = controller.makeMenuCardItem( + Text("Subscription Utilization"), + id: "usageHistorySubmenu", + width: StatusItemController.menuCardBaseWidth) + menu.addItem(usageHistoryItem) + menu.addItem(NSMenuItem( + title: String(repeating: "W", count: 60), + action: nil, + keyEquivalent: "")) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth > StatusItemController.menuCardBaseWidth) + + controller.refreshMenuCardHeights(in: menu) + + #expect(abs((usageHistoryItem.view?.frame.width ?? 0) - expectedWidth) <= 0.5) + } + + @Test + func `rendered menu width keeps tracked window width after AppKit shrink`() { + let width = StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: 356) + + #expect(width == 356) + #expect(StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: nil) == 310) + } + + @Test + func `data only repopulate reuses menu card hosting views`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstPass = self.cardViewIdentities(in: menu) + #expect(!firstPass.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + let secondPass = self.cardViewIdentities(in: menu) + + #expect(secondPass.keys.sorted() == firstPass.keys.sorted()) + for (id, identity) in firstPass { + #expect(secondPass[id] == identity, "card \(id) should reuse its hosting view") + } + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `merged data tick reconciles items in place without churn`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + let registry = ProviderRegistry.shared + let enabled: Set = [.codex, .claude] + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.selectedMenuProvider = .codex + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let itemsBefore = menu.items.map(ObjectIdentifier.init) + let cardViewsBefore = self.cardViewIdentities(in: menu) + #expect(!cardViewsBefore.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + + let itemsAfter = menu.items.map(ObjectIdentifier.init) + #expect(itemsAfter == itemsBefore, "data-only repopulate should not remove or insert menu items") + let cardViewsAfter = self.cardViewIdentities(in: menu) + for (id, identity) in cardViewsBefore { + #expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view") + } + } + + @Test + func `reconcile keeps matching edge rows when the middle differs`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + func plainItem(_ title: String) -> NSMenuItem { + NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + + let menu = NSMenu() + menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300)) + menu.addItem(.separator()) + menu.addItem(plainItem("Old Provider Action")) + menu.addItem(plainItem("Old Provider Detail")) + menu.addItem(.separator()) + menu.addItem(plainItem("Settings")) + let cardItem = menu.items[0] + let cardView = cardItem.view + let settingsItem = menu.items[5] + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300)) + scratch.addItem(.separator()) + scratch.addItem(plainItem("New Provider Action")) + scratch.addItem(.separator()) + scratch.addItem(plainItem("Settings")) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 5) + #expect(menu.items[0] === cardItem, "card row should be updated in place") + #expect(menu.items[0].view === cardView, "card hosting view should be recycled in place") + #expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place") + #expect(menu.items[2].title == "New Provider Action") + } + + @Test + func `reconcile preserves highlight on a retained custom action row`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = NSMenuItem() + liveItem.isEnabled = true + liveItem.representedObject = "action" + liveItem.view = RecordingMenuHighlightView() + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + + let replacementView = RecordingMenuHighlightView() + let replacementItem = NSMenuItem() + replacementItem.isEnabled = true + replacementItem.representedObject = "action" + replacementItem.view = replacementView + let scratch = NSMenu() + scratch.addItem(replacementItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === replacementView) + #expect(replacementView.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile restores highlight on a retained recycled card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let hosting = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: nil, + preserveHighlightedItem: true) + defer { controller.clearMenuCardViewRecyclePool() } + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === hosting) + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `harvesting consumes only the displaced selection cache entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(controller.menuCardViewRecyclePool.count == 1) + #expect(item.view == nil) + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `harvesting consumes displaced cache when card rendering is disabled`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `type compatible leftover is adopted across card identifiers`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300) + + #expect(switched.view === originalView) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `recycled card keeps its hosting view and highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(original) + guard let originalView = original.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300) + + #expect(rebuilt.view === originalView) + guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView> + else { + Issue.record("expected the recycled hosting view") + return + } + #expect(rebuiltView.highlightState === originalView.highlightState) + rebuiltView.setHighlighted(true) + #expect(rebuiltView.highlightState.isHighlighted) + rebuiltView.setHighlighted(false) + } + + @Test + func `harvesting a highlighted card clears its highlight and tracking entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + controller.menu(menu, willHighlight: item) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item) + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + + let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300) + #expect(rebuilt.view === hosting) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `same id with different content type builds a fresh view`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300) + + #expect(rebuilt.view != nil) + #expect(rebuilt.view !== originalView) + // The incompatible pool entry is consumed rather than left behind. + #expect(controller.menuCardViewRecyclePool.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 411ed124b..fe27c4412 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -488,6 +488,29 @@ struct MiMoProviderTests { } } + @Test + func `mimo cookie importer surfaces safari access denial`() throws { + let detection = BrowserDetection( + homeDirectory: "/tmp/codexbar-mimo-browser-test", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }) + + do { + _ = try MiMoCookieImporter.importSessions( + browserDetection: detection, + loadRecords: { browser, _, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Grant CodexBar Full Disk Access to read Safari cookies.") + }) + Issue.record("Expected Safari access denial") + } catch let error as MiMoSettingsError { + #expect(error.localizedDescription.contains("Full Disk Access")) + #expect(error.localizedDescription.contains("Safari")) + } + } + @Test func `mimo web strategy retries imported sessions after decode failure`() async throws { KeychainCacheStore.setTestStoreForTesting(true) diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index 53c367f61..369ed9cc1 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -206,6 +206,66 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Idle prune is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.2) + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + var lease: OpenAIDashboardWebViewLease? = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + lease?.release() + lease = nil + + #expect(cache.hasCachedEntry(for: store), "WebView should remain cached right after release") + + let deadline = Date().addingTimeInterval(5) + while cache.hasCachedEntry(for: store), Date() < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } + + #expect( + !cache.hasCachedEntry(for: store), + "Expected the scheduled idle prune to evict the WebView without any further cache activity") + + cache.clearAllForTesting() + } + + @Test + func `Later release does not postpone an older idle entry`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 5) + let firstStore = WKWebsiteDataStore.nonPersistent() + let secondStore = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let firstLease = try await cache.acquire( + websiteDataStore: firstStore, + usageURL: url, + logger: nil) + firstLease.release() + let firstDeadline = try #require(cache.idlePruneDeadlineForTesting) + + try await Task.sleep(for: .milliseconds(50)) + + let secondLease = try await cache.acquire( + websiteDataStore: secondStore, + usageURL: url, + logger: nil) + secondLease.release() + let rescheduledDeadline = try #require(cache.idlePruneDeadlineForTesting) + + #expect( + abs(rescheduledDeadline.timeIntervalSince(firstDeadline)) < 0.001, + "A later release should keep the prune scheduled for the oldest idle entry") + #expect(cache.hasCachedEntry(for: firstStore)) + #expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window") + cache.clearAllForTesting() + } + @Test func `Reused page reset clears one shot scraper globals`() async throws { if self.shouldSkipOnCI() { return } diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index 1a063ce78..14a6bfe3a 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -91,6 +91,58 @@ struct PiSessionCostScannerTests { #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) } + @Test + func `pi scanner keeps ambiguous claude errors priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-fable-5", + "stopReason": "error", + "errorMessage": "An unknown error occurred", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 100, + "output": 0, + "cacheRead": 20, + "cacheWrite": 10, + "totalTokens": 130, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-06-09T10-00-00-000Z_refusal.jsonl", + contents: env.jsonl([claudeEntry])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 130) + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 0) + + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + @Test func `pi scanner uses model change fallback and assistant timestamp day`() throws { let env = try CostUsageTestEnvironment() @@ -425,7 +477,7 @@ struct PiSessionCostScannerTests { defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), @@ -494,12 +546,12 @@ struct PiSessionCostScannerTests { } @Test - func `pi scanner ignores v1 cache missing usage sample counts`() throws { + func `pi scanner ignores v2 cache with stale claude pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), @@ -557,7 +609,7 @@ struct PiSessionCostScannerTests { totalTokens: 300_000, costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), costSampleCount: 2, - usageSampleCount: nil) + usageSampleCount: 2) let dayKey = "2026-05-10" let contributions = [ UsageProvider.claude.rawValue: [ @@ -572,7 +624,7 @@ struct PiSessionCostScannerTests { parsedBytes: size, lastModelContext: nil, contributions: contributions) - var oldCache = PiSessionCostCache(version: 1) + var oldCache = PiSessionCostCache(version: 2) oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) oldCache.scanSinceKey = dayKey oldCache.scanUntilKey = dayKey @@ -580,7 +632,7 @@ struct PiSessionCostScannerTests { oldCache.files = [fileURL.path: oldFileUsage] let oldCacheURL = env.cacheRoot .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v1.json", isDirectory: false) + .appendingPathComponent("pi-sessions-v2.json", isDirectory: false) try FileManager.default.createDirectory( at: oldCacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) @@ -602,9 +654,12 @@ struct PiSessionCostScannerTests { #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + let newCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] - #expect(newCache.version == 2) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v3.json") + #expect(newCache.version == 3) #expect(rebuilt?.usageSampleCount == 2) #expect(rebuilt?.costSampleCount == 2) } diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift new file mode 100644 index 000000000..8ee80c99b --- /dev/null +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import CodexBar + +struct PlanUtilizationHistoryChartMenuViewTests { + @Test + func `merged entries preserve first occurrence order while removing duplicates`() { + let first = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 100), + usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 200)) + let second = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 300), + usedPercent: 20, + resetsAt: nil) + + let merged = PlanUtilizationHistoryChartMenuView.mergedEntries([ + first, + second, + first, + second, + ]) + + #expect(merged == [first, second]) + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index c60add8c0..770f17752 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -81,6 +81,14 @@ struct PreferencesPaneSmokeTests { #expect(L("tab_general") == "通用") #expect(L("quota_warning_notifications_title") == "配额预警通知") #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + + settings.appLanguage = "ja" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ja") + #expect(L("language_title") == "言語") + #expect(L("start_at_login_title") == "ログイン時に起動") + // Fork: ja.lproj displayed values are rebranded to QuotaKit. + #expect(L("quit_app") == "QuotaKit を終了") } private static func makeSettingsStore(suite: String) -> SettingsStore { diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift index c07b43e06..c2fa5fc06 100644 --- a/Tests/CodexBarTests/ProviderHTTPClientTests.swift +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -129,6 +129,71 @@ struct ProviderHTTPClientTests { #expect(response.statusCode == 403) #expect(await script.requestCount() == 1) } + + @Test + func `redirect guard blocks cross origin redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://attacker.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks non HTTPS redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "http://provider.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks redirects without an original URL`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: nil, + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks port changes`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example:8443/usage"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard preserves same origin HTTPS requests`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Authorization") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + redirectRequest.setValue("application/json", forHTTPHeaderField: "Accept") + + let guarded = try #require(ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest)) + + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Authorization") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "x-api-key") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Accept") == "application/json") + } } extension ProviderHTTPRetryPolicy { diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 6db1d3c33..65751b0a4 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -24,6 +24,7 @@ struct ProviderIconResourcesTests { "antigravity", "factory", "copilot", + "devin", "crof", "commandcode", "t3chat", diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 43ae2fe0e..29c9178ec 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -182,6 +182,20 @@ struct ProviderSettingsDescriptorTests { #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) } + @Test + func `devin presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") + fixture.store.lastSourceLabels[.devin] = "web" + let metadata = try #require(ProviderDescriptorRegistry.metadata[.devin]) + let context = fixture.presentationContext(provider: .devin, metadata: metadata) + + let detailLine = DevinProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "web") + } + @Test func `alibaba token plan settings expose cookie controls`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 8879df6d7..2b1a82010 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -272,15 +272,6 @@ struct ProviderStorageFootprintTests { #expect(detailView.copyablePaths.contains("\(root)/file-history")) } - @Test - @MainActor - func `storage path copy button writes exact path to pasteboard`() { - let path = "/Users/test/.claude/projects/example" - StoragePathCopyButton.copyToPasteboard(path) - - #expect(NSPasteboard.general.string(forType: .string) == path) - } - @Test @MainActor func `manual storage refresh updates deleted provider data`() async throws { diff --git a/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift new file mode 100644 index 000000000..27cc01113 --- /dev/null +++ b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import Testing +@testable import CodexBar + +@MainActor +struct ProviderSwitcherEventPeekGateTests { + @Test + func `first check always peeks`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + } + + @Test + func `unchanged counters skip the peek`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .leftMouseDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + #expect(!gate.shouldPeek()) + } + + @Test + func `any advanced counter re-enables the peek`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .leftMouseDown], + counterProvider: { type in type == .keyDown ? keyDownCount : 3 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `counter change keeps one follow up peek for AppKit queue delivery`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown], + counterProvider: { _ in keyDownCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `queued unhandled event burst keeps peeking until the queue is empty`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 3 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `handled event keeps peeking for delayed sibling from same counter snapshot`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 2 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `held key keeps peeking for uncounted autorepeat events`() throws { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .keyUp], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyDown, keyCode: 124)) + #expect(gate.shouldPeek()) + #expect(gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + private static func keyEvent(type: NSEvent.EventType, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index 5bad3fe8d..ff7591752 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -179,6 +179,133 @@ struct StatusItemAnimationSignatureTests { #expect(button.imagePosition == .imageLeft) } + @Test + func `merged icon render defers while merged menu is tracking`() async throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-defers-during-tracking" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .synthetic) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + store._setSnapshotForTesting(snapshot(usedPercent: 20), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + controller.applyIcon(phase: nil) + let initialSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.menuWillOpen(menu) + #expect(controller.isMergedMenuOpen) + + store._setSnapshotForTesting(nil, provider: .codex) + for _ in 0..<10 where controller.animationDriver == nil { + await Task.yield() + } + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + store._setSnapshotForTesting(snapshot(usedPercent: 80), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature == initialSignature) + + controller.startQuotaWarningFlash(provider: .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + + let quotaWarningTask = controller.quotaWarningFlashTasks[.codex] + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + quotaWarningTask?.cancel() + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuDidClose(menu) + + #expect(!controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuWillOpen(menu) + settings.selectedMenuProvider = .synthetic + #expect(controller.primaryProviderForUnifiedIcon() == .synthetic) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + controller.startQuotaWarningFlash(provider: .codex) + let switchedProviderWarningTask = controller.quotaWarningFlashTasks[.codex] + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + switchedProviderWarningTask?.cancel() + controller.menuDidClose(menu) + + settings.selectedMenuProvider = .codex + for _ in 0..<10 where controller.primaryProviderForUnifiedIcon() != .codex { + await Task.yield() + } + + controller.menuWillOpen(menu) + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateAnimationState() + controller.applyIcon(phase: controller.animationPhase) + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + controller.animationDriver?.stop() + controller.animationDriver = nil + controller.animationPhase = 0 + controller.menuDidClose(menu) + + #expect(controller.animationDriver == nil) + // Fork: with no snapshot data the deferred render applies the + // QuotaKit app-icon fallback, whose signature has no primary field. + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("mode=appIcon") == true) + } + @Test func `merged fallback provider follows enabled provider order`() throws { let suite = "StatusItemAnimationSignatureTests-merged-provider-order" diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift index e2e1fbaf3..92ebb005f 100644 --- a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -32,6 +32,9 @@ extension StatusMenuTests { defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } let menu = controller.makeMenu() // Simulate a closed menu that was attached by an icon update but has never been opened. controller.fallbackMenu = menu @@ -80,6 +83,9 @@ extension StatusMenuTests { defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } let menu = controller.makeMenu() controller.fallbackMenu = menu controller.statusItem.menu = menu @@ -101,6 +107,8 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == openedVersion) store.isRefreshing = false + controller.fallbackMenu = menu + controller.statusItem.menu = menu controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) for _ in 0..<40 where controller.menuVersions[key] == openedVersion { await Task.yield() diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 4ad6ce803..c7feea478 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -223,6 +223,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -285,6 +286,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -449,6 +451,7 @@ struct StatusMenuCodexSwitcherTests { codexHomePath: "/Users/test/.codex", observedAt: Date()) settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift index b1f73389a..b09889fc4 100644 --- a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -1,3 +1,6 @@ +import AppKit +import CodexBarCore +import SwiftUI import Testing @testable import CodexBar @@ -43,4 +46,85 @@ struct StatusMenuCostMenuCardTests { "Cost refresh failed.", ]) } + + @Test + func `rendered cost menu keeps long dynamic details inside fixed row width`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $227.42 - 267M tokens - " + String(repeating: "wide ", count: 20), + monthLine: "Last 30 days: $52,431.09 - 77B tokens - " + String(repeating: "wide ", count: 20), + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let model = self.makeModel(tokenUsage: tokenUsage) + let submenu = NSMenu() + + let item = controller.makeCostMenuCardItem( + model: model, + submenu: submenu, + width: width) + let view = try #require(item.view) + + #expect(view is any MenuCardMeasuring) + #expect(abs(view.frame.width - width) <= 0.5) + #expect(item.title == "Cost") + #expect(item.toolTip?.contains("$52,431.09") == true) + #expect(item.submenu === submenu) + #expect(item.target === controller) + #expect(item.action.map(NSStringFromSelector) == "menuCardNoOp:") + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCostMenuCardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeModel( + tokenUsage: UsageMenuCardView.Model.TokenUsageSection) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: "Pro", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: tokenUsage, + placeholder: nil, + progressColor: .blue) + } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 2a6323058..b509d9985 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -43,11 +43,11 @@ struct StatusMenuHostedSubmenuRefreshTests { controller.menuVersions[parentKey] = controller.menuContentVersion let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) - #expect(costItem.view == nil) + #expect(costItem.view is any MenuCardMeasuring) let submenu = try #require(costItem.submenu) let submenuAction = try #require(costItem.action) - #expect(NSStringFromSelector(submenuAction) == "submenuAction:") - #expect((costItem.target as? NSMenu) === submenu) + #expect(NSStringFromSelector(submenuAction) == "menuCardNoOp:") + #expect(costItem.target === controller) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) @@ -196,6 +196,81 @@ struct StatusMenuHostedSubmenuRefreshTests { } } + @Test + func `zai chart render signature follows time range boundaries`() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + let beforeMidnight = try #require(formatter.date(from: "2026-01-01 23:30")) + let afterMidnight = try #require(formatter.date(from: "2026-01-02 00:30")) + let modelUsage = ZaiModelUsageData( + xTime: ["2026-01-01 23:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100]), + ]) + + let before = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: beforeMidnight) + let after = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: afterMidnight) + + #expect(before != after) + } + + @Test + func `utilization chart invalidates when active account changes`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + Self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [Self.makePlanHistory(usedPercent: 20)], + bobKey: [Self.makePlanHistory(usedPercent: 50)], + ]) + settings.setActiveTokenAccountIndex(0, for: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let aliceView = try #require(submenu.items.first?.view) + + settings.setActiveTokenAccountIndex(1, for: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let bobView = try #require(submenu.items.first?.view) + #expect(bobView !== aliceView) + } + private func assertHostedChartItemHeightMatchesRefresh( chartID: String, provider: UsageProvider, @@ -286,6 +361,14 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(hydratedItem.toolTip == provider.rawValue) #expect(hydratedItem.view != nil) #expect(hydratedItem.title != "No data available") + let hydratedView = hydratedItem.view + let inflatedHeight = hydratedView.map { view -> CGFloat in + let inflatedHeight = view.frame.height + 100 + if chartID == StatusItemController.zaiHourlyUsageChartID { + view.frame.size.height = inflatedHeight + } + return inflatedHeight + } controller.refreshHostedSubviewMenu(submenu) @@ -294,6 +377,19 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(refreshedItem.toolTip == provider.rawValue) #expect(refreshedItem.view != nil) #expect(refreshedItem.title != "No data available") + #expect(refreshedItem.view === hydratedView) + if chartID == StatusItemController.zaiHourlyUsageChartID { + #expect(refreshedItem.view?.frame.height != inflatedHeight) + } + + if chartID == StatusItemController.costHistoryChartID, provider == .claude { + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(dailyCost: 2.34), provider: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let changedItem = try #require(submenu.items.first) + #expect(changedItem.view != nil) + #expect(changedItem.view !== hydratedView) + } } private static func makeSettings() -> SettingsStore { @@ -370,15 +466,19 @@ struct StatusMenuHostedSubmenuRefreshTests { self.seedClaudeSnapshots(in: store) store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( unscoped: [ - PlanUtilizationSeriesHistory( - name: .session, - windowMinutes: 300, - entries: [ - PlanUtilizationHistoryEntry( - capturedAt: Date(timeIntervalSince1970: 1_700_000_000), - usedPercent: 24, - resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), - ]), + self.makePlanHistory(usedPercent: 24), + ]) + } + + private static func makePlanHistory(usedPercent: Double) -> PlanUtilizationSeriesHistory { + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: usedPercent, + resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), ]) } @@ -419,19 +519,19 @@ struct StatusMenuHostedSubmenuRefreshTests { store._setSnapshotForTesting(snapshot, provider: .zai) } - private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + private static func makeTokenSnapshot(dailyCost: Double = 1.23) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, - last30DaysCostUSD: 1.23, + last30DaysCostUSD: dailyCost, daily: [ CostUsageDailyReport.Entry( date: "2025-12-23", inputTokens: nil, outputTokens: nil, totalTokens: 123, - costUSD: 1.23, + costUSD: dailyCost, modelsUsed: nil, modelBreakdowns: nil), ], diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift new file mode 100644 index 000000000..00dd8dbb4 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -0,0 +1,1540 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `opening fresh menu does not schedule deferred refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(providerRefreshCount == 0) + #expect(refreshInteractions.isEmpty) + } + + @Test + func `menu open with missing data refreshes asynchronously while tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!controller.deferredMenuInteractionRefreshPending) + controller.menuDidClose(menu) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `menu open renders cached data immediately after data only invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedItemCount = menu.items.count + let cachedVersion = controller.menuVersions[key] + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + let dataOnlyVersion = controller.menuContentVersion + var asyncRebuilds = 0 + controller._test_openMenuRebuildObserver = { _ in + asyncRebuilds += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.menuWillOpen(menu) + + #expect(cachedVersion != dataOnlyVersion) + #expect(menu.items.count == cachedItemCount) + #expect(controller.menuVersions[key] == cachedVersion) + #expect(asyncRebuilds == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where asyncRebuilds == 0 { + await Task.yield() + } + + #expect(asyncRebuilds == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.didMenuAdjunctReadinessChange()) + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + } + + @Test + func `closing before cached menu rebuild keeps next open stale`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + var rebuildGateEntries = 0 + var rebuildGate: CheckedContinuation? + controller._test_openMenuRefreshYieldOverride = { + rebuildGateEntries += 1 + await withCheckedContinuation { continuation in + rebuildGate = continuation + } + } + defer { + rebuildGate?.resume() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where rebuildGateEntries == 0 { + await Task.yield() + } + + #expect(rebuildGateEntries == 1) + #expect(controller.menuVersions[key] == cachedVersion) + controller.menuDidClose(menu) + #expect(controller.menuNeedsRefresh(menu)) + + rebuildGate?.resume() + rebuildGate = nil + controller._test_openMenuRefreshYieldOverride = nil + for _ in 0..<20 { + await Task.yield() + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + controller.menuDidClose(menu) + } + + @Test + func `menu open rebuilds synchronously after provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `overview menu rebuilds synchronously after secondary provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "codex@example.com"), + provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller.selectedMenuProvider = .codex + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com", provider: .claude), + provider: .claude) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `stacked Codex menu rebuilds synchronously after secondary account identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "selected@example.com"), + provider: .codex) + let selectedAccount = CodexVisibleAccount( + id: "selected", + email: "selected@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let secondaryAccount = CodexVisibleAccount( + id: "secondary", + email: "secondary@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: selectedAccount, + snapshot: self.instantOpenSnapshot(email: "selected@example.com"), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "old@example.com"), + error: nil, + sourceLabel: "test"), + ] + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store.codexAccountSnapshots[1] = CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "new@example.com"), + error: nil, + sourceLabel: "test") + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `cache preserving structural invalidation rebuilds synchronously on open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.menuContentVersion != controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `data invalidation after cache preserving structural invalidation still rebuilds synchronously`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + let structuralVersion = controller.menuContentVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.latestStructuralMenuContentVersion == structuralVersion) + #expect(controller.menuContentVersion == controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `menu open does not overlap provider specific refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(store.refreshingProviders.contains(.codex)) + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!store.isRefreshing) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `cached menu rebuilds after active provider refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuNeedsRefresh(menu)) + + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `menu rebuilds after displayed provider completes while another provider refreshes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + store.refreshingProviders = [.claude] + defer { store.refreshingProviders = [] } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `user refresh supersedes background provider refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + } + await refreshGate.waitUntilStarted(count: 1) + + let userRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh() + } + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await userRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .userInitiated]) + } + + @Test + func `settings refresh supersedes background provider refresh without becoming user initiated`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + await refreshGate.waitUntilStarted(count: 1) + + let settingsRefreshTask = Task { + await store.refreshForSettingsChange() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await settingsRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + } + + @Test + func `superseded provider refresh drains before newer result`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderRefresh() + let baseSpec = try #require(store.providerSpecs[.claude]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderFetchStrategy { + await refreshes.awaitSnapshot() + } + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.claude) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.claude) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + await refreshes.resume( + call: 1, + snapshot: self.instantOpenSnapshot( + email: "old@example.com", + provider: .claude, + percent: 10)) + await refreshes.waitUntilStarted(count: 2) + await refreshes.resume( + call: 2, + snapshot: self.instantOpenSnapshot( + email: "new@example.com", + provider: .claude, + percent: 80)) + await newerTask.value + await olderTask.value + + #expect(store.snapshot(for: .claude)?.primary?.usedPercent == 80) + #expect(store.snapshot(for: .claude)?.accountEmail(for: .claude) == "new@example.com") + } + + @Test + func `superseded provider refresh cannot overwrite manually changed token`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + settings.stepfunToken = "user-token" + await refreshes.resume(call: 1, token: "old-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "user-token") + await refreshes.resume(call: 2, token: "new-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "new-token") + } + + @Test + func `superseded provider refresh preserves rotated token when credential is unchanged`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + + await refreshes.resume(call: 1, token: "rotated-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "rotated-token") + await refreshes.resume(call: 2, token: "newer-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "newer-token") + } + + @Test + func `canceling provider refresh cancels its owned probe task`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let refreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + refreshTask.cancel() + await refreshGate.releaseFirst() + await refreshTask.value + + #expect(refreshWasCancelled) + } + + @Test + func `canceling refresh owner keeps shared provider probe alive`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + let sharedWaiterTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + + ownerTask.cancel() + await refreshGate.releaseFirst() + await ownerTask.value + await sharedWaiterTask.value + + #expect(!refreshWasCancelled) + #expect(await refreshGate.startCount == 1) + } + + @Test + func `background refresh retries canceled provider probe with cached data`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "cached@example.com"), + provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + ownerTask.cancel() + let backgroundTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + await refreshGate.releaseFirst() + await ownerTask.value + await backgroundTask.value + + #expect(await refreshGate.startCount == 2) + } + + @Test + func `menu open refresh only retries the displayed provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.refreshingProviders.insert(.claude) + defer { store.refreshingProviders.remove(.claude) } + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { provider in + refreshedProviders.append(provider) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 where refreshedProviders.isEmpty { + await Task.yield() + } + + #expect(refreshedProviders == [.codex]) + } + + @Test + func `opening fresh split menu preserves another provider deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() + } + + let codexMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(codexMenu) + controller.menuDidClose(codexMenu) + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + let claudeMenu = controller.makeMenu(for: .claude) + controller.menuWillOpen(claudeMenu) + defer { controller.menuDidClose(claudeMenu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `overview defers only providers that need retry`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + } + + @Test + func `closing overview menu stops before refreshing another provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .openai], settings: settings) + settings.updateProviderConfig(provider: .openai) { config in + config.apiKey = "test-openai-key" + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting(nil, provider: .openai) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { _ in + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + for _ in 0..<80 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during missing data refresh preserves deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + #expect(controller.deferredMenuInteractionRefreshPending) + #expect(store.refreshingProviders.contains(.codex)) + + let periodicRefreshTask = Task { + await store.refresh() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + controller.menuDidClose(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + await refreshGate.releaseFirst() + await periodicRefreshTask.value + for _ in 0..<40 where store.isRefreshing { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + await refreshGate.waitUntilStarted(count: 2) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during successful missing data refresh clears deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + + for _ in 0..<80 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + private func enableOnlyCodexForInstantOpenTesting(_ settings: SettingsStore) { + self.enableProvidersForInstantOpenTesting([.codex], settings: settings) + } + + private func instantOpenSnapshot( + email: String, + provider: UsageProvider = .codex, + percent: Double = 25) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "ChatGPT")) + } + + private func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + settings: SettingsStore) + { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + } +} + +private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async -> UsageSnapshot + + var id: String { + "instant-open-provider-refresh-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = await self.loader() + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct InstantOpenProviderMutationFetchStrategy: ProviderFetchStrategy { + let mutations: OrderedInstantOpenProviderMutation + + let id = "instant-open-provider-mutation-test" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let token = await self.mutations.awaitToken() + await context.providerManualTokenUpdater?(.stepfun, token) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor OrderedInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitSnapshot() async -> UsageSnapshot { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, snapshot: UsageSnapshot) { + self.continuations.removeValue(forKey: call)?.resume(returning: snapshot) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor OrderedInstantOpenProviderMutation { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitToken() async -> String { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, token: String) { + self.continuations.removeValue(forKey: call)?.resume(returning: token) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor BlockingInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var firstReleaseWaiters: [CheckedContinuation] = [] + private var firstReleased = false + + var startCount: Int { + self.started + } + + func run() async { + self.started += 1 + self.resumeReadyStartWaiters() + guard self.started == 1, !self.firstReleased else { return } + await withCheckedContinuation { continuation in + self.firstReleaseWaiters.append(continuation) + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let waiters = self.firstReleaseWaiters + self.firstReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 21e57ae44..4a523d593 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -5,112 +5,6 @@ import Testing @testable import CodexBar extension StatusMenuTests { - @Test - func `opening fresh menu does not schedule deferred refresh`() async { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = false - self.enableOnlyCodex(settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - var providerRefreshCount = 0 - var refreshInteractions: [ProviderInteraction] = [] - store._test_providerRefreshOverride = { provider in - guard provider == .codex else { return } - refreshInteractions.append(ProviderInteractionContext.current) - providerRefreshCount += 1 - } - defer { store._test_providerRefreshOverride = nil } - - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) - defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } - - controller.menuRefreshEnabledOverrideForTesting = true - StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) - defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - for _ in 0..<20 { - await Task.yield() - } - #expect(providerRefreshCount == 0) - #expect(!controller.deferredMenuInteractionRefreshPending) - - controller.menuDidClose(menu) - for _ in 0..<40 { - await Task.yield() - } - - #expect(providerRefreshCount == 0) - #expect(refreshInteractions.isEmpty) - } - - @Test - func `menu open with missing data defers automatic refresh until tracking ends`() async { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = false - self.enableOnlyCodex(settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - store._setSnapshotForTesting(nil, provider: .codex) - var providerRefreshCount = 0 - var refreshInteractions: [ProviderInteraction] = [] - store._test_providerRefreshOverride = { provider in - guard provider == .codex else { return } - refreshInteractions.append(ProviderInteractionContext.current) - providerRefreshCount += 1 - } - defer { store._test_providerRefreshOverride = nil } - - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) - defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } - - controller.menuRefreshEnabledOverrideForTesting = true - StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) - defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - for _ in 0..<20 { - await Task.yield() - } - #expect(providerRefreshCount == 0) - #expect(controller.deferredMenuInteractionRefreshPending) - - controller.menuDidClose(menu) - for _ in 0..<40 where providerRefreshCount == 0 { - await Task.yield() - } - - #expect(providerRefreshCount == 1) - #expect(refreshInteractions == [.background]) - #expect(!controller.deferredMenuInteractionRefreshPending) - } - @Test func `store observation marks open menu stale without rebuilding during tracking`() async { self.disableMenuCardsForTesting() @@ -193,10 +87,20 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.mergedMenu = menu controller.statusItem.menu = menu + for _ in 0..<20 { + await Task.yield() + } controller.populateMenu(menu, provider: nil) controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + controller.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) let openedVersion = controller.menuVersions[key] // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so @@ -256,6 +160,12 @@ extension StatusMenuTests { controller.populateMenu(menu, provider: nil) controller.markMenuFresh(menu) let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() let openedVersion = controller.menuVersions[key] controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) @@ -270,6 +180,9 @@ extension StatusMenuTests { controller.menuWillOpen(menu) defer { controller.menuDidClose(menu) } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } #expect(controller.menuVersions[key] == controller.menuContentVersion) } @@ -629,7 +542,7 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() async { + func `explicit store actions defer visible parent menu rebuild`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -660,17 +573,18 @@ extension StatusMenuTests { defer { controller._test_openMenuRebuildObserver = nil } controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.menuContentVersion != openedVersion) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] != openedVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `repeated explicit store actions coalesce to one open menu rebuild`() async { + func `repeated explicit store actions keep parent rebuild deferred`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -703,16 +617,17 @@ extension StatusMenuTests { controller.refreshOpenMenusAfterExplicitStoreAction() controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + func `explicit refresh keeps stale parent deferred after hosted submenu closes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -755,13 +670,14 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == openedVersion) controller.menuDidClose(submenu) - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.openMenus[submenuKey] == nil) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[menuKey] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) } @Test diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index df65ad9e4..4311deaa5 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -125,4 +125,147 @@ extension StatusMenuTests { ($0.representedObject as? String) == "overviewRow-zai" }) } + + @Test + func `selecting overview row defers provider detail rebuild`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .cursor) + #expect(rebuildCount == 0) + #expect(menu.items.contains { + ($0.representedObject as? String)?.hasPrefix("overviewRow-") == true + }) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(rebuildCount == 1) + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } + + @Test + func `overview row action close renders selected provider on next open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + controller.menuDidClose(menu) + + await Task.yield() + await Task.yield() + #expect(rebuildCount == 0) + #expect(settings.selectedMenuProvider == .cursor) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 67acc94dc..f5e256950 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -495,20 +495,37 @@ struct StatusMenuSwitcherClickTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + controller.menuRefreshEnabledOverrideForTesting = true + defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 1 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 2) } @Test diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index a49fc7c1c..c10999cb1 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -156,7 +156,7 @@ struct StatusMenuSwitcherRefreshTests { } @Test - func `merged provider switch restores cached tab content`() async throws { + func `merged provider switch updates live tab rows in place`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(true) @@ -198,12 +198,14 @@ struct StatusMenuSwitcherRefreshTests { } defer { controller._test_openMenuRebuildObserver = nil } + // Provider switches now reconcile matching rows in place instead of parking and + // restoring distinct item sets per tab: the same NSMenuItem objects carry each + // tab's freshly built content, so AppKit never relayouts the open menu per insert. let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - let alternateContentID = ObjectIdentifier(menu.items[contentStartIndex]) - #expect(alternateContentID != originalContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) @@ -215,7 +217,7 @@ struct StatusMenuSwitcherRefreshTests { #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) == alternateContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) controller.invalidateMenus() #expect(controller.mergedSwitcherContentCaches.isEmpty) @@ -253,9 +255,7 @@ struct StatusMenuSwitcherRefreshTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) - let originalContent = try #require( - menu.items.indices.contains(contentStartIndex) ? menu.items[contentStartIndex] : nil) - let originalContentID = ObjectIdentifier(originalContent) + #expect(menu.items.indices.contains(contentStartIndex)) let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) @@ -266,6 +266,7 @@ struct StatusMenuSwitcherRefreshTests { defer { controller._test_openMenuRebuildObserver = nil } controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) @@ -274,8 +275,17 @@ struct StatusMenuSwitcherRefreshTests { #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + // Rows are reconciled in place, so freshness is guaranteed by rebuilding content + // from current data rather than by minting new items: the live menu must be marked + // fresh and no cached entry may predate the required invalidation. (In-place item + // identity itself is covered deterministically in MenuCardViewRecyclingTests; here + // async gate state may legitimately route a populate through the full rebuild.) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) != originalContentID) + let menuKey = ObjectIdentifier(menu) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values { + #expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion) + } } @Test diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 96d2d1bd5..e4758c755 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -563,12 +563,19 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") - for _ in 0..<20 - where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView).map(ObjectIdentifier.init) - { + for _ in 0..<20 { await Task.yield() } + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(ObjectIdentifier(menu))) + if let initialSwitcherID, let currentSwitcher = menu.items.first?.view as? ProviderSwitcherView { + #expect(initialSwitcherID == ObjectIdentifier(currentSwitcher)) + } + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) if let initialSwitcherID, let updatedSwitcher { @@ -1657,50 +1664,4 @@ extension StatusMenuTests { #expect(claudeRow.action != nil) #expect(claudeRow.target is StatusItemController) } - - @Test - func `selecting overview row switches to provider detail`() throws { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - settings.mergedMenuLastSelectedWasOverview = true - - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .cursor - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - } - - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - let cursorRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-cursor" - }) - let action = try #require(cursorRow.action) - let target = try #require(cursorRow.target as? StatusItemController) - _ = target.perform(action, with: cursorRow) - - #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .cursor) - - let ids = self.representedIDs(in: menu) - #expect(ids.contains("menuCard")) - #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) - #expect(self.switcherButtons(in: menu).first(where: { $0.state == .on })?.tag == 2) - } } diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 49053cc93..f63a5b764 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -101,6 +101,13 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false + // Fork: iCloud sync defaults on and fans out a fetch per token + // account, so the global refresh alone would put two fetches in + // flight. Disable it so this test exercises upstream's + // single-selected-account path and its refresh-coalescing + // assertion; the sync fan-out is covered by + // ShouldFetchAllTokenAccountsTests. + settings.iCloudSyncEnabled = false self.enableOnlyClaude(settings) settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") @@ -131,10 +138,15 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) - await blocker.waitUntilStarted(count: 2) XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + for _ in 0..<40 { + await Task.yield() + } + let startedBeforeDrain = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeDrain, 1) await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await blocker.waitUntilStarted(count: 2) await selectionTask.value await refreshTask.value let startedCallCount = await blocker.startedCallCount() diff --git a/Tests/CodexBarTests/TerminalAppTests.swift b/Tests/CodexBarTests/TerminalAppTests.swift new file mode 100644 index 000000000..97a6b491e --- /dev/null +++ b/Tests/CodexBarTests/TerminalAppTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CodexBar + +@Suite("TerminalApp") +struct TerminalAppTests { + @Test + @MainActor + func `default is terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + @MainActor + func `setting terminal app persists it`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.terminalApp = .iTerm + #expect(store.terminalApp == .iTerm) + #expect(defaults.string(forKey: "terminalApp") == "iTerm") + } + + @Test + @MainActor + func `invalid stored value falls back to terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.set("nonexistent", forKey: "terminalApp") + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + func `only two cases exist`() { + #expect(TerminalApp.allCases.count == 2) + } + + @Test + func `all cases have unique bundle identifiers`() { + let ids = TerminalApp.allCases.map(\.bundleIdentifier) + #expect(Set(ids).count == TerminalApp.allCases.count) + } + + @Test + func `all cases have non-empty labels`() { + for app in TerminalApp.allCases { + #expect(!app.label.isEmpty) + } + } + + @Test + func `round-trip all cases through raw value`() { + for app in TerminalApp.allCases { + #expect(TerminalApp(rawValue: app.rawValue) == app) + } + } + + @Test + func `escapes commands embedded in AppleScript strings`() { + let escaped = TerminalApp.escapeForAppleScript(#"echo "C:\tmp""#) + + #expect(escaped == #"echo \"C:\\tmp\""#) + } + + @Test + func `builds terminal-specific launch scripts`() { + let command = #"echo "hello""# + let terminalScript = TerminalApp.terminal.appleScript(command: command) + let iTermScript = TerminalApp.iTerm.appleScript(command: command) + + #expect(terminalScript.contains(#"tell application "Terminal""#)) + #expect(terminalScript.contains(#"do script "echo \"hello\"""#)) + #expect(iTermScript.contains(#"tell application "iTerm""#)) + #expect(iTermScript.contains(#"write text "echo \"hello\"""#)) + } +} diff --git a/TestsLinux/CostUsageScanExecutorLinuxTests.swift b/TestsLinux/CostUsageScanExecutorLinuxTests.swift new file mode 100644 index 000000000..e286e535c --- /dev/null +++ b/TestsLinux/CostUsageScanExecutorLinuxTests.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct CostUsageScanExecutorLinuxTests { + @Test + func returnsWorkValue() async throws { + let value = try await CostUsageScanExecutor.run { _ in 42 } + #expect(value == 42) + } + + @Test + func cancelledTaskThrowsCancellationError() async { + let task = Task { + try await CostUsageScanExecutor.run { checkCancellation in + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index bcd0b99a8..27e98931c 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -104,6 +104,11 @@ This script: 5. Launches `QuotaKit.app` 6. Verifies it stays running +Launching an unbundled `CodexBar` executable, including SwiftPM builds using `.build` or a custom scratch path, disables +Keychain access for that process to avoid repeated password prompts. Use the packaged `CodexBar.app` when local +validation needs browser cookies or stored credentials; packaged app bundles keep their normal Keychain behavior +regardless of signing mode. + When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the cached browser/OAuth state available across normal rebuilds. diff --git a/docs/devin.md b/docs/devin.md new file mode 100644 index 000000000..4e5d24752 --- /dev/null +++ b/docs/devin.md @@ -0,0 +1,43 @@ +--- +summary: "Devin provider auth, quota endpoint, and setup." +read_when: + - Adding or modifying the Devin provider + - Debugging Devin localStorage import or quota parsing + - Explaining Devin setup +--- + +# Devin Provider + +The Devin provider tracks included daily and weekly usage quotas from +[app.devin.ai](https://app.devin.ai). + +## Setup + +1. Sign in to Devin in Google Chrome. +2. Open the organization Usage & Limits page once. +3. Enable **Devin** in **Settings → Providers**. + +Automatic mode reads only the Devin session and organization metadata from Chrome localStorage. It does not scan other +browsers. QuotaKit sends the session token only to `https://app.devin.ai`. + +## Manual Auth + +Set **Auth source** to **Manual**, then paste either the bare token or the full `Authorization: Bearer ...` header value +from an app.devin.ai API request. The optional organization field accepts a slug, an internal `org_...` ID, or the full +organization URL. + +Environment overrides: + +- `DEVIN_BEARER_TOKEN` or `DEVIN_AUTHORIZATION` +- `DEVIN_ORGANIZATION` or `DEVIN_ORG` + +## Data Source + +QuotaKit requests: + +```text +GET https://app.devin.ai/api//billing/quota/usage +``` + +The response supplies daily and weekly usage percentages plus reset timestamps. If Devin changes or expires the browser +session, sign in again and refresh QuotaKit. diff --git a/docs/mimo.md b/docs/mimo.md index be6fb0636..71cc3aeb2 100644 --- a/docs/mimo.md +++ b/docs/mimo.md @@ -22,6 +22,10 @@ The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo consol 2. Enable **Xiaomi MiMo** 3. Leave **Cookie source** on **Auto** (recommended) +QuotaKit imports cookies from these browsers in order: **Safari**, **Chrome** / **Chrome Beta** / **Chrome Canary**, **Firefox**, and **Microsoft Edge**. Switch to **Manual** and paste a `Cookie:` header if your active MiMo session lives in Arc, Brave, or another browser profile QuotaKit does not auto-detect. + +Safari cookie import may require granting QuotaKit Full Disk Access in **System Settings → Privacy & Security**. + ### Manual cookie import (optional) 1. Open `https://platform.xiaomimimo.com/#/console/balance` @@ -39,12 +43,13 @@ The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo consol - MiMo currently exposes **balance only** - Token cost, status polling, debug log output, and widgets are not supported yet +- Auto import covers Safari, Chrome variants, Firefox, and Edge only; other browsers use **Manual** mode ## Troubleshooting ### “No Xiaomi MiMo browser session found” -Log in at `https://platform.xiaomimimo.com/#/console/balance` in Chrome, then refresh CodexBar. +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Safari, Chrome, Firefox, or Edge, then refresh QuotaKit. If your session lives in another browser, switch the MiMo provider to **Cookie source → Manual** and paste the `Cookie:` header instead. ### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” diff --git a/docs/providers.md b/docs/providers.md index 18f523522..a891bd337 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 48 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +QuotaKit currently registers 49 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -33,6 +33,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | +| Devin | Chrome localStorage session or manual Bearer token → daily and weekly quota API (`web`). | | z.ai | API token from config/env → quota API (`api`). | | Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | | MiniMax | Manual/browser session via Coding Plan web path (`web`), or Coding Plan API token (`api`). | @@ -107,6 +108,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: none yet. - Details: `docs/zai.md`. +## Devin +- Automatic auth reads the current `auth1_session` token and organization metadata from Chrome localStorage. +- Manual auth accepts the `Authorization: Bearer ...` value from an app.devin.ai request. +- Usage endpoint: `GET /api//billing/quota/usage`. +- Shows daily and weekly quota percentages with their reset timestamps. +- Details: `docs/devin.md`. + ## Manus - Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`. - Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`. diff --git a/version.env b/version.env index 3a5783b63..181a53ee1 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-06-06 # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=d7b58a050cb18bfc3eac41400d238b84839461e5 +UPSTREAM_MONITOR_BASE=dd8cf8b06ebb761cd850f194bd2d8e8aeffffc4d