diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffd2072c..81d636828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Changed - Menu bar: remove status-item hover tooltips while retaining VoiceOver titles. - Cost displays: use consistent labels while preserving reported-versus-estimated provenance in settings and per-value hints. -- Synced upstream CodexBar changes through `f8636cb37`, including OpenCode Go source scoping and sub-1% usage accuracy, macOS 14 TaskLocal launch stability, weekly progress selection, custom-layout icon tinting, widget cost-row compaction, and Command Code contrast while preserving QuotaKit release ownership, CloudKit setup, config paths, and iOS build numbers. +- Synced upstream CodexBar changes through `02b4ba278`, including Claude weekly-window ordering and fallback accuracy, Kimi weekly duration accuracy, Ollama session reuse, safer automatic Safari-cookie handling, LongCat Firefox imports, localized session equivalents, stacked menu-title alignment, WidgetKit refresh-loop prevention, and Linux Alibaba Token Plan support while preserving QuotaKit release ownership, CloudKit setup, config paths, and iOS build numbers. ### Fixed - Refresh: prevent macOS 14 launch crashes caused by TaskLocal task-allocation corruption. @@ -35,6 +35,7 @@ on Columbus Labs QuotaKit releases and product-facing changes. - Codex: exclude parent-copied prefixes from compact subagent usage when the fork boundary matches the parent snapshot. - Usage & Spend: render share-card PNG exports without black backgrounds and keep complete model rows visible beside incomplete sources. - ElevenLabs: clamp character and voice-slot usage percentages at 100% during overage. +- Ollama: reuse validated browser sessions and skip inaccessible Safari cookies during automatic fallback while retaining explicit permission guidance. ## 0.32.4.12 / iOS 1.11.3 — 2026-07-17 diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 38a6918e8..d9fc9a335 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -214,7 +214,22 @@ func L(_ key: String, language: String) -> String { } func codexBarLocalizedLocale() -> Locale { - let language = resolvedAppLanguage() + codexBarLocale(forLanguage: resolvedAppLanguage()) +} + +/// Returns the locale of the resource bundle currently selected by `L`. +/// +/// This can differ from `Locale.current` when the app falls back to a supported language. Plural +/// formatting must use this locale so it follows the same language as the resolved strings. +func codexBarLocalizedResourceLocale() -> Locale { + let bundleURL = localizedBundle().bundleURL + guard bundleURL.pathExtension == "lproj" else { + return codexBarLocalizedLocale() + } + return codexBarLocale(forLanguage: bundleURL.deletingPathExtension().lastPathComponent) +} + +private func codexBarLocale(forLanguage language: String) -> Locale { guard !language.isEmpty else { return .current } let normalized = language.lowercased() if normalized == "ar" || normalized.hasPrefix("ar-") { diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift index 27204013e..8d5b5455c 100644 --- a/Sources/CodexBar/MenuBarLayoutRenderer.swift +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -91,6 +91,7 @@ final class MenuBarLayoutTitleCache { @MainActor final class MenuBarLayoutRenderer { private static let missingValue = "–" + private static let stackedBaselineOffset: CGFloat = -3 // Center multi-line NSStatusBarButton titles. private struct TokenStyle { let font: NSFont @@ -138,11 +139,14 @@ final class MenuBarLayoutRenderer { paragraphStyle.maximumLineHeight = 9.5 paragraphStyle.lineSpacing = -1 } - let attributes: [NSAttributedString.Key: Any] = [ + var attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: foregroundColor, .paragraphStyle: paragraphStyle, ] + if isStacked { + attributes[.baselineOffset] = Self.stackedBaselineOffset + } let result = NSMutableAttributedString() var accessibilityLines: [String] = [] diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index f0b01e1ce..78381922b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1408,7 +1408,11 @@ extension UsageMenuCardView.Model { primaryPaceOnTop = paceDetail.paceOnTop } } - } else if let paceDetail = Self.resetWindowPaceDetail(window: primary, input: input) { + } else if let paceDetail = Self.resetWindowPaceDetail( + window: primary, + input: input, + pace: input.provider == .kimi ? input.weeklyPace : nil) + { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel primaryPacePercent = paceDetail.pacePercent @@ -1468,12 +1472,21 @@ extension UsageMenuCardView.Model { title: String? = nil, zaiTimeDetail: String?) -> Metric { - var paceDetail = Self.weeklyPaceDetail( - provider: input.provider, - window: weekly, - now: input.now, - pace: input.weeklyPace, - showUsed: input.usageBarsShowUsed) + // Kimi's secondary slot is its 5-hour rate limit rather than a weekly window. + var paceDetail = if input.provider == .kimi { + Self.sessionPaceDetail( + provider: input.provider, + window: weekly, + now: input.now, + showUsed: input.usageBarsShowUsed) + } else { + Self.weeklyPaceDetail( + provider: input.provider, + window: weekly, + now: input.now, + pace: input.weeklyPace, + showUsed: input.usageBarsShowUsed) + } var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil if input.provider == .warp, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 1c65cc2b2..f1503ea77 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -539,8 +539,8 @@ struct ProvidersPane: View { tokenError = nil } - // Abacus uses primary for monthly credits (no secondary window) - let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary + // Abacus and Kimi carry their long-cadence window in primary rather than secondary. + let paceWindow = provider == .abacus || provider == .kimi ? snapshot?.primary : snapshot?.secondary let weeklyPace = if let codexProjection, let weekly = codexProjection.rateWindow(for: .weekly) { diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift index 7e888c5c6..5ec689ec4 100644 --- a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift @@ -97,7 +97,21 @@ struct OllamaProviderImplementation: ProviderImplementation { binding: cookieBinding, options: cookieOptions, isVisible: nil, - onChange: nil), + onChange: nil, + trailingText: { + guard context.settings.ollamaUsageDataSource != .api else { return nil } + return ProviderCookieRefreshAction.trailingText( + provider: .ollama, + cookieSource: context.settings.ollamaCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( + provider: .ollama, + cookieSource: { context.settings.ollamaCookieSource }, + additionalVisibility: { context.settings.ollamaUsageDataSource != .api }, + context: context), + ]), ] } diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift index eb3e7958a..2ceb1320e 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift @@ -11,13 +11,14 @@ enum ProviderCookieRefreshAction { static func descriptor( provider: UsageProvider, cookieSource: @escaping () -> ProviderCookieSource, + additionalVisibility: @escaping () -> Bool = { true }, context: ProviderSettingsContext) -> ProviderSettingsActionDescriptor { ProviderSettingsActionDescriptor( id: "\(provider.rawValue)-reimport-cookie", title: "Refresh", style: .bordered, - isVisible: { cookieSource() == .auto }, + isVisible: { cookieSource() == .auto && additionalVisibility() }, perform: { await self.perform(provider: provider, context: context) }) diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index d58683c2f..64d5bad33 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -88,8 +88,8 @@ extension StatusItemController { let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - // Abacus uses primary for monthly credits (no secondary window) - let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary + // Abacus and Kimi carry their long-cadence window in primary rather than secondary. + let paceWindow = target == .abacus || target == .kimi ? snapshot?.primary : snapshot?.secondary let sessionEquivalentHistorySelection = self.sessionEquivalentHistorySelection( provider: target, snapshot: snapshot, diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 53c0318df..fab95f947 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -39,19 +39,21 @@ enum UsagePaceText { static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { let displayedEstimate = Self.boundedFullWindowCount(forecast.estimatedWindowsToExhaustWeekly) - let numberText = String.localizedStringWithFormat( - L("≈%d full 5h windows of weekly left · %d windows until reset"), - displayedEstimate, - forecast.windowsUntilReset) + let formattingLocale = codexBarLocalizedResourceLocale() + let numberText = String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: formattingLocale, + arguments: [displayedEstimate, forecast.windowsUntilReset]) let verdictText: String if forecast.estimatedWindowsToExhaustWeekly >= forecast.availableWindowsUntilReset { verdictText = L("Weekly cannot run out before reset at this pace") } else { let windowsEarly = Self.boundedWindowCount( forecast.availableWindowsUntilReset - forecast.estimatedWindowsToExhaustWeekly) - verdictText = String.localizedStringWithFormat( - L("Weekly can run out ≈%d windows early"), - max(1, windowsEarly)) + verdictText = String( + format: L("Weekly can run out ≈%d windows early"), + locale: formattingLocale, + arguments: [max(1, windowsEarly)]) } return SessionEquivalentDetail( verdictText: verdictText, @@ -151,7 +153,8 @@ enum UsagePaceText { } static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity + guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity || + provider == .kimi else { return nil } if provider == .ollama, window.windowMinutes == nil { return nil @@ -159,6 +162,9 @@ enum UsagePaceText { if provider == .antigravity, let windowMinutes = window.windowMinutes, windowMinutes != 300 { return nil } + if provider == .kimi, window.windowMinutes != KimiProviderDescriptor.sessionWindowMinutes { + return nil + } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil } guard pace.expectedUsedPercent >= 3 else { return nil } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index be230a092..bc1d6540d 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -544,10 +544,20 @@ enum CLIRenderer { now: Date = Date()) -> ProviderPacePayload? { let primary = snapshot.primary.flatMap { - self.pacePayload(provider: provider, window: $0, kind: .session, now: now) + self.pacePayload( + provider: provider, + window: $0, + kind: self.paceKind(provider: provider, fallback: .session), + weeklyWorkDays: weeklyWorkDays, + now: now) } let secondary = snapshot.secondary.flatMap { - self.pacePayload(provider: provider, window: $0, kind: .weekly, weeklyWorkDays: weeklyWorkDays, now: now) + self.pacePayload( + provider: provider, + window: $0, + kind: self.paceKind(provider: provider, fallback: .weekly), + weeklyWorkDays: weeklyWorkDays, + now: now) } guard primary != nil || secondary != nil else { return nil } return ProviderPacePayload(primary: primary, secondary: secondary) @@ -577,7 +587,7 @@ enum CLIRenderer { provider: provider, title: labels.primary, window: primary, - paceKind: .session, + paceKind: self.paceKind(provider: provider, fallback: .session), context: context, now: now, lines: &lines) @@ -609,7 +619,7 @@ enum CLIRenderer { provider: provider, title: labels.secondary, window: weekly, - paceKind: .weekly, + paceKind: self.paceKind(provider: provider, fallback: .weekly), context: context, now: now, lines: &lines) @@ -1067,13 +1077,25 @@ enum CLIRenderer { func supports(provider: UsageProvider) -> Bool { switch self { case .session: - provider == .codex || provider == .claude || provider == .ollama + provider == .codex || provider == .claude || provider == .ollama || provider == .kimi case .weekly: - provider == .codex || provider == .claude || provider == .opencode || provider == .ollama + provider == .codex || provider == .claude || provider == .opencode || provider == .ollama || + provider == .kimi } } } + private static func paceKind(provider: UsageProvider, fallback: PaceKind) -> PaceKind { + guard provider == .kimi else { return fallback } + // Kimi stores weekly in primary and its rate limit in secondary, opposite the legacy CLI slot convention. + switch fallback { + case .session: + return .weekly + case .weekly: + return .session + } + } + private static func computePace( provider: UsageProvider, window: RateWindow, @@ -1082,6 +1104,16 @@ enum CLIRenderer { now: Date) -> UsagePace? { guard kind.supports(provider: provider) else { return nil } + if provider == .kimi { + let supportsWindow = switch kind { + case .session: + window.windowMinutes == KimiProviderDescriptor.sessionWindowMinutes + case .weekly: + ProviderDescriptorRegistry.descriptor(for: provider).pace + .supportsResetWindowPace(window: window, now: now) + } + guard supportsWindow else { return nil } + } // Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary. if case .session = kind, let minutes = window.windowMinutes, minutes > 300 { return nil diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 97e815cd9..9f17cdbfb 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -699,6 +699,13 @@ extension CodexBarCLI { { return false } + if provider == .alibabatokenplan, + settings?.alibabaTokenPlan?.cookieSource == .manual + { + // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies; only browser + // cookie auto-import needs macOS, so a manual cookie header works off macOS too. + return false + } #if os(Linux) if provider == .cursor, settings?.cursor?.cookieSource != .off diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 2b31092a4..52c1f8695 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -208,18 +208,14 @@ struct OAuthUsageResponse: Decodable { in container: KeyedDecodingContainer, keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) { - var firstNullKey: String? for keyName in keys { guard let key = DynamicCodingKey(stringValue: keyName) else { continue } guard container.contains(key) else { continue } if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) { return (value, keyName) } - if firstNullKey == nil { - firstNullKey = keyName - } } - return (nil, firstNullKey) + return (nil, nil) } private static func decodeValue( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 7cfb1abf1..e4aa81d1d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -234,7 +234,7 @@ extension ClaudeStatusProbe { // Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. let weeklyModels = Set(labelContext.lines.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch)) - let hasAllModelsWeeklyLabel = weeklyModels.contains("allmodels") + let hasAllModelsWeeklyLabel = weeklyModels.contains(where: self.isAllModelsWeeklyModel) let opusModels = Set(opusLabels.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch)) let hasOpusLabel = !weeklyModels.isDisjoint(with: opusModels) @@ -341,6 +341,17 @@ extension ClaudeStatusProbe { } private static func extractPercent(labelSubstring: String, context: LabelSearchContext) -> Int? { + // Prefer an exact label match; only fall back to a fuzzy (garbled-capture) match when no exact + // copy yields a value, so a clean row always wins over a corrupted duplicate regardless of order. + self.extractPercent(labelSubstring: labelSubstring, context: context, allowFuzzy: false) + ?? self.extractPercent(labelSubstring: labelSubstring, context: context, allowFuzzy: true) + } + + private static func extractPercent( + labelSubstring: String, + context: LabelSearchContext, + allowFuzzy: Bool) -> Int? + { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, line) in lines.enumerated() { @@ -349,7 +360,8 @@ extension ClaudeStatusProbe { line: line, normalizedLine: normalizedLine, labelSubstring: labelSubstring, - normalizedLabel: label) + normalizedLabel: label, + allowFuzzy: allowFuzzy) else { continue } // Claude's usage panel can take a moment to render percentages (especially on enterprise accounts), @@ -359,7 +371,8 @@ extension ClaudeStatusProbe { if self.crossesLabelBoundary( line: candidate, labelSubstring: labelSubstring, - normalizedLabel: label) + normalizedLabel: label, + allowFuzzy: allowFuzzy) { break } @@ -413,7 +426,7 @@ extension ClaudeStatusProbe { for (index, line) in context.lines.enumerated() { guard let modelName = self.weeklyModelName(from: line) else { continue } let normalizedModel = self.normalizedForLabelSearch(modelName) - guard normalizedModel != "allmodels", !normalizedModel.isEmpty else { continue } + guard !normalizedModel.isEmpty, !self.isAllModelsWeeklyModel(normalizedModel) else { continue } let window = context.lines.dropFirst(index).prefix(14) var percentLeft: Int? @@ -746,6 +759,16 @@ extension ClaudeStatusProbe { } private static func extractReset(labelSubstring: String, context: LabelSearchContext) -> String? { + // Exact match wins over a garbled duplicate, mirroring extractPercent's candidate selection. + self.extractReset(labelSubstring: labelSubstring, context: context, allowFuzzy: false) + ?? self.extractReset(labelSubstring: labelSubstring, context: context, allowFuzzy: true) + } + + private static func extractReset( + labelSubstring: String, + context: LabelSearchContext, + allowFuzzy: Bool) -> String? + { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, line) in lines.enumerated() { @@ -754,7 +777,8 @@ extension ClaudeStatusProbe { line: line, normalizedLine: normalizedLine, labelSubstring: labelSubstring, - normalizedLabel: label) + normalizedLabel: label, + allowFuzzy: allowFuzzy) else { continue } let window = lines.dropFirst(idx).prefix(14) @@ -762,7 +786,8 @@ extension ClaudeStatusProbe { if self.crossesLabelBoundary( line: candidate, labelSubstring: labelSubstring, - normalizedLabel: label) + normalizedLabel: label, + allowFuzzy: allowFuzzy) { break } @@ -778,19 +803,29 @@ extension ClaudeStatusProbe { line: String, normalizedLine: String, labelSubstring: String, - normalizedLabel: String) -> Bool + normalizedLabel: String, + allowFuzzy: Bool = false) -> Bool { guard let expectedModel = self.weeklyModelName(from: labelSubstring) else { return normalizedLine.contains(normalizedLabel) } guard let actualModel = self.weeklyModelName(from: line) else { return false } - return self.normalizedForLabelSearch(actualModel) == self.normalizedForLabelSearch(expectedModel) + let expectedNormalized = self.normalizedForLabelSearch(expectedModel) + let actualNormalized = self.normalizedForLabelSearch(actualModel) + // When looking up the all-models weekly bucket, tolerate garbled TUI captures ("all modls") + // so the Weekly percent/reset are still recovered even if the clean copy never survived. The + // fuzzy match is opt-in so callers can prefer an exact copy before accepting a corrupted one. + if allowFuzzy, self.isAllModelsWeeklyModel(expectedNormalized) { + return self.isAllModelsWeeklyModel(actualNormalized) + } + return actualNormalized == expectedNormalized } private static func crossesLabelBoundary( line: String, labelSubstring: String, - normalizedLabel: String) -> Bool + normalizedLabel: String, + allowFuzzy: Bool = false) -> Bool { let normalizedLine = self.normalizedForLabelSearch(line) guard normalizedLine.hasPrefix("current") else { return false } @@ -798,7 +833,12 @@ extension ClaudeStatusProbe { return !normalizedLine.contains(normalizedLabel) } guard let actualModel = self.weeklyModelName(from: line) else { return true } - return self.normalizedForLabelSearch(actualModel) != self.normalizedForLabelSearch(expectedModel) + let expectedNormalized = self.normalizedForLabelSearch(expectedModel) + let actualNormalized = self.normalizedForLabelSearch(actualModel) + if allowFuzzy, self.isAllModelsWeeklyModel(expectedNormalized) { + return !self.isAllModelsWeeklyModel(actualNormalized) + } + return actualNormalized != expectedNormalized } private static func weeklyModelName(from line: String) -> String? { @@ -814,6 +854,42 @@ extension ClaudeStatusProbe { return String(line[modelRange]).trimmingCharacters(in: .whitespacesAndNewlines) } + /// Recognizes the "all models" weekly bucket even when the TUI capture dropped or duplicated a + /// character (e.g. "all modls"). Claude renders `/usage` as a redrawing TUI, so the same + /// "Current week (all models)" line can be captured mid-repaint; without tolerant matching that + /// garbled copy escapes the all-models filter and is surfaced as a bogus second weekly row + /// ("all modls only") beside the real Weekly limit. Genuine model-scoped names (Opus, Sonnet, + /// Fable, …) are far outside this edit-distance window. + private static func isAllModelsWeeklyModel(_ normalizedModel: String) -> Bool { + normalizedModel == "allmodels" || self.editDistance(normalizedModel, "allmodels") <= 2 + } + + /// Levenshtein distance. Inputs here are short normalized model tokens, so the naive + /// single-row implementation is more than fast enough. + private static func editDistance(_ lhs: String, _ rhs: String) -> Int { + let a = Array(lhs.unicodeScalars) + let b = Array(rhs.unicodeScalars) + if a.isEmpty { + return b.count + } + if b.isEmpty { + return a.count + } + var row = Array(0...b.count) + for i in 1...a.count { + var previousDiagonal = row[0] + row[0] = i + for j in 1...b.count { + let deletion = row[j] + 1 + let insertion = row[j - 1] + 1 + let substitution = previousDiagonal + (a[i - 1] == b[j - 1] ? 0 : 1) + previousDiagonal = row[j] + row[j] = Swift.min(deletion, insertion, substitution) + } + } + return row[b.count] + } + private static func extractReset(labelSubstrings: [String], context: LabelSearchContext) -> String? { for label in labelSubstrings { if let value = self.extractReset(labelSubstring: label, context: context) { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 39050fc08..d3557f205 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1130,29 +1130,15 @@ extension ClaudeUsageFetcher { } private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { - let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ - ( - id: "claude-routines", - title: "Daily Routines", - window: usage.sevenDayRoutines, - sourceKey: usage.sevenDayRoutinesSourceKey), + let definitions: [(id: String, title: String, window: OAuthUsageWindow?)] = [ + (id: "claude-routines", title: "Daily Routines", window: usage.sevenDayRoutines), ] if let routinesKey = usage.sevenDayRoutinesSourceKey { Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") } let routineWindows: [NamedRateWindow] = definitions.compactMap { definition in - let utilization: Double - let resetDate: Date? - if let window = definition.window, let parsedUtilization = window.utilization { - utilization = parsedUtilization - resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) - } else if definition.sourceKey != nil { - // Keep product bars visible when the API returns a known key with null payload. - utilization = 0 - resetDate = nil - } else { - return nil - } + guard let window = definition.window, let utilization = window.utilization else { return nil } + let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) let resetDescription = resetDate.map(Self.formatResetDate) return NamedRateWindow( id: definition.id, @@ -1163,7 +1149,9 @@ extension ClaudeUsageFetcher { resetsAt: resetDate, resetDescription: resetDescription)) } - return routineWindows + Self.oauthScopedWeeklyLimitWindows(from: usage) + // Keep the same row order as the Web path: model-scoped weekly limits first, + // Daily Routines last. + return Self.oauthScopedWeeklyLimitWindows(from: usage) + routineWindows } private static func oauthScopedWeeklyLimitWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift index ae19e6bce..4022987a3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -25,33 +25,23 @@ enum ClaudeWebExtraRateWindowParser { var sourceKeys: [String: String] = [:] windows.reserveCapacity(Self.definitions.count) - for definition in Self.definitions { - if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { - let rawWindow = foundWindow.window - guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } - let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) - windows.append(Self.namedWindow( - id: definition.id, - title: definition.title, - usedPercent: utilization, - resetsAt: resetsAt)) - sourceKeys[definition.id] = foundWindow.sourceKey - continue - } - - // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). - // Preserve the bar in that case with a 0% window so the product section remains visible. - if let key = Self.firstUsageKey(in: json, keys: definition.keys) { - windows.append(Self.namedWindow( - id: definition.id, - title: definition.title, - usedPercent: 0, - resetsAt: nil)) - sourceKeys[definition.id] = key - } - } let scopedLimits = Self.scopedWeeklyLimits(from: json) + // Model-scoped weekly limits come first: they track the model budget users spend + // day to day, while Daily Routines would otherwise push the meaningful row down. windows.append(contentsOf: ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: scopedLimits)) + + for definition in Self.definitions { + guard let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) else { continue } + let rawWindow = foundWindow.window + guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } + let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: utilization, + resetsAt: resetsAt)) + sourceKeys[definition.id] = foundWindow.sourceKey + } return ( windows, sourceKeys, @@ -101,13 +91,6 @@ enum ClaudeWebExtraRateWindowParser { return nil } - private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { - for key in keys where json.keys.contains(key) { - return key - } - return nil - } - private static func percentValue(from value: Any?) -> Double? { if let intValue = value as? Int { return Double(intValue) diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift index 7b1a6d73f..48fdc103b 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift @@ -107,12 +107,29 @@ public struct KimiUsageDetail: Codable, Sendable { } } -struct KimiRateLimit: Codable { +struct KimiRateLimit: Codable, Sendable { let window: KimiWindow let detail: KimiUsageDetail } -struct KimiWindow: Codable { +struct KimiWindow: Codable, Sendable { let duration: Int let timeUnit: String + + var durationMinutes: Int? { + guard self.duration > 0 else { return nil } + let multiplier: Int + switch self.timeUnit { + case "TIME_UNIT_MINUTE": + multiplier = 1 + case "TIME_UNIT_HOUR": + multiplier = 60 + case "TIME_UNIT_DAY": + multiplier = 24 * 60 + default: + return nil + } + let result = self.duration.multipliedReportingOverflow(by: multiplier) + return result.overflow ? nil : result.partialValue + } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index badf408bf..24e00f129 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -1,6 +1,8 @@ import Foundation public enum KimiProviderDescriptor { + public static let sessionWindowMinutes = 5 * 60 + public static let weeklyWindowMinutes = 7 * 24 * 60 public static let descriptor: ProviderDescriptor = Self.makeDescriptor() static func makeDescriptor() -> ProviderDescriptor { @@ -35,6 +37,8 @@ public enum KimiProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kimi cost summary is not supported." }), + pace: ProviderPaceCapability( + resetWindowPace: .windowDuration(minutes: self.weeklyWindowMinutes)), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index eab5005ea..115fa75f2 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -134,9 +134,11 @@ public struct KimiUsageFetcher: Sendable { subscriptionStats = nil } + let rateLimit = codingUsage.limits?.first return KimiUsageSnapshot( weekly: codingUsage.detail, - rateLimit: codingUsage.limits?.first?.detail, + rateLimit: rateLimit?.detail, + rateLimitWindow: rateLimit?.window, subscriptionBalance: subscriptionStats?.subscriptionBalance, subscriptionCodeWeeklyLimit: subscriptionStats?.ratelimitCode7d, updatedAt: now) @@ -179,9 +181,12 @@ public struct KimiUsageFetcher: Sendable { private static func parseCodeAPIUsage(from data: Data, now: Date) throws -> KimiUsageSnapshot { let response = try JSONDecoder().decode(KimiCodeAPIUsageResponse.self, from: data) + let rateLimit = response.limits?.first return KimiUsageSnapshot( weekly: response.usage, - rateLimit: response.limits?.first?.detail, + rateLimit: rateLimit?.detail, + rateLimitWindow: rateLimit?.window, + subscriptionBalance: nil, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index 98433d9db..029f25737 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -4,6 +4,7 @@ public struct KimiUsageSnapshot: Sendable { public let weekly: KimiUsageDetail public let rateLimit: KimiUsageDetail? public let updatedAt: Date + let rateLimitWindow: KimiWindow? let subscriptionBalance: KimiSubscriptionBalance? let subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? @@ -11,6 +12,7 @@ public struct KimiUsageSnapshot: Sendable { self.weekly = weekly self.rateLimit = rateLimit self.updatedAt = updatedAt + self.rateLimitWindow = nil self.subscriptionBalance = nil self.subscriptionCodeWeeklyLimit = nil } @@ -18,12 +20,14 @@ public struct KimiUsageSnapshot: Sendable { init( weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, + rateLimitWindow: KimiWindow? = nil, subscriptionBalance: KimiSubscriptionBalance?, subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? = nil, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit + self.rateLimitWindow = rateLimitWindow self.subscriptionBalance = subscriptionBalance self.subscriptionCodeWeeklyLimit = subscriptionCodeWeeklyLimit self.updatedAt = updatedAt @@ -50,44 +54,68 @@ public struct KimiUsageSnapshot: Sendable { private static func clampedPercent(_ value: Double) -> Double { min(100, max(0, value)) } + + private static func usageCounts(_ detail: KimiUsageDetail) -> (used: Int, limit: Int, isReliable: Bool)? { + guard let limit = Int(detail.limit), limit > 0 else { return nil } + + // Used is authoritative and may exceed the limit during overage; remaining must describe a valid balance. + if let rawUsed = detail.used, + let used = Int(rawUsed), + used >= 0 + { + return (used, limit, true) + } + + if let rawRemaining = detail.remaining, + let remaining = Int(rawRemaining), + (0...limit).contains(remaining) + { + return (limit - remaining, limit, true) + } + + // Preserve the legacy 0% gauge for a valid limit, but withhold duration so invalid counters cannot create pace. + return (0, limit, false) + } + + private static func rateLimitDescription(used: Int, limit: Int, windowMinutes: Int?) -> String { + guard let windowMinutes else { return "Rate: \(used)/\(limit)" } + if windowMinutes.isMultiple(of: 60) { + let hours = windowMinutes / 60 + return "Rate: \(used)/\(limit) per \(hours) \(hours == 1 ? "hour" : "hours")" + } + return "Rate: \(used)/\(limit) per \(windowMinutes) \(windowMinutes == 1 ? "minute" : "minutes")" + } } extension KimiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { // Parse weekly quota - let weeklyLimit = Int(weekly.limit) ?? 0 - let weeklyRemaining = Int(weekly.remaining ?? "") - let weeklyUsed = Int(weekly.used ?? "") ?? { - guard let remaining = weeklyRemaining else { return 0 } - return max(0, weeklyLimit - remaining) - }() - - let weeklyPercent = weeklyLimit > 0 ? Self.clampedPercent(Double(weeklyUsed) / Double(weeklyLimit) * 100) : 0 - - let weeklyWindow = RateWindow( - usedPercent: weeklyPercent, - windowMinutes: nil, // Weekly doesn't have a fixed window like rate limit - resetsAt: Self.parseDate(self.weekly.resetTime), - resetDescription: "\(weeklyUsed)/\(weeklyLimit) requests") + // Both Kimi usage endpoints expose FEATURE_CODING usage.detail as the weekly quota. + let weeklyWindow = Self.usageCounts(self.weekly).map { counts in + RateWindow( + usedPercent: Self.clampedPercent(Double(counts.used) / Double(counts.limit) * 100), + windowMinutes: counts.isReliable ? KimiProviderDescriptor.weeklyWindowMinutes : nil, + resetsAt: Self.parseDate(self.weekly.resetTime), + resetDescription: "\(counts.used)/\(counts.limit) requests") + } // Parse rate limit if available - var rateLimitWindow: RateWindow? - if let rateLimit = self.rateLimit { - let rateLimitValue = Int(rateLimit.limit) ?? 0 - if rateLimitValue > 0 { - let rateRemaining = Int(rateLimit.remaining ?? "") - let rateUsed = Int(rateLimit.used ?? "") ?? { - guard let remaining = rateRemaining else { return 0 } - return max(0, rateLimitValue - remaining) - }() - let ratePercent = Self.clampedPercent(Double(rateUsed) / Double(rateLimitValue) * 100) - - rateLimitWindow = RateWindow( - usedPercent: ratePercent, - windowMinutes: 300, // 300 minutes = 5 hours - resetsAt: Self.parseDate(rateLimit.resetTime), - resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours") + let rateLimitWindow = self.rateLimit.flatMap { rateLimit -> RateWindow? in + guard let counts = Self.usageCounts(rateLimit) else { return nil } + let apiWindowMinutes: Int? = if let apiWindow = self.rateLimitWindow { + apiWindow.durationMinutes + } else { + KimiProviderDescriptor.sessionWindowMinutes } + let windowMinutes = counts.isReliable ? apiWindowMinutes : nil + return RateWindow( + usedPercent: Self.clampedPercent(Double(counts.used) / Double(counts.limit) * 100), + windowMinutes: windowMinutes, + resetsAt: Self.parseDate(rateLimit.resetTime), + resetDescription: Self.rateLimitDescription( + used: counts.used, + limit: counts.limit, + windowMinutes: windowMinutes)) } let monthlyWindow = self.subscriptionBalance.flatMap { balance -> NamedRateWindow? in @@ -98,7 +126,7 @@ extension KimiUsageSnapshot { guard let ratio = balance.amountUsedRatio, ratio.isFinite else { return nil } let window = RateWindow( usedPercent: Self.clampedPercent(ratio * 100), - windowMinutes: nil, + windowMinutes: nil, // Calendar-month duration varies; do not fabricate a fixed 30-day window. resetsAt: Self.parseDate(balance.expireTime), resetDescription: nil) return NamedRateWindow(id: "kimi-monthly", title: "Monthly", window: window) @@ -109,7 +137,7 @@ extension KimiUsageSnapshot { guard let ratio = limit.ratio, ratio.isFinite else { return nil } let window = RateWindow( usedPercent: Self.clampedPercent(ratio * 100), - windowMinutes: 7 * 24 * 60, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, resetsAt: Self.parseDate(limit.resetTime), resetDescription: nil) return NamedRateWindow(id: "kimi-code-7d", title: "Code 7-day", window: window) diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index df315adfd..ce7292a4b 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -79,10 +79,33 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { let logger: ((String) -> Void)? = context.verbose ? { msg in CodexBarLog.logger(LogCategories.ollama).verbose(msg) } : nil - let snap = try await fetcher.fetch( - cookieHeaderOverride: manual, - manualCookieMode: isManualMode, - logger: logger) + let snap: OllamaUsageSnapshot = if isManualMode { + try await fetcher.fetch( + cookieHeaderOverride: manual, + manualCookieMode: true, + logger: logger) + } else { + try await Self.fetchAutomatic( + cached: CookieHeaderCache.load(provider: .ollama), + fetchCached: { cached in + try await fetcher.fetchResolvedCookie( + cookieHeaderOverride: cached.cookieHeader, + cookieHeaderOverrideSourceLabel: "cached \(cached.sourceLabel)", + logger: logger).snapshot + }, + fetchBrowser: { + try await fetcher.fetchResolvedCookie(logger: logger) + }, + clearCached: { cached in + _ = CookieHeaderCache.clearIfCurrent(provider: .ollama, expected: cached) + }, + storeResolved: { resolved in + CookieHeaderCache.store( + provider: .ollama, + cookieHeader: resolved.cookieHeader, + sourceLabel: resolved.sourceLabel) + }) + } return self.makeResult( usage: snap.toUsageSnapshot(), sourceLabel: "web") @@ -97,6 +120,57 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { guard context.settings?.ollama?.cookieSource == .manual else { return nil } return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader) } + + static func fetchAutomatic( + cached: CookieHeaderCache.Entry?, + fetchCached: (CookieHeaderCache.Entry) async throws -> OllamaUsageSnapshot, + fetchBrowser: () async throws -> OllamaUsageFetcher.ResolvedCookieFetch, + clearCached: (CookieHeaderCache.Entry) -> Void, + storeResolved: (OllamaUsageFetcher.ResolvedCookieFetch) -> Void) async throws -> OllamaUsageSnapshot + { + if let cached { + do { + return try await fetchCached(cached) + } catch { + if self.shouldInvalidateCachedCookie(after: error) { + clearCached(cached) + } else if self.shouldTryBrowserCandidates(afterCachedFailure: error) { + let cachedError = error + do { + let resolved = try await fetchBrowser() + storeResolved(resolved) + return resolved.snapshot + } catch { + throw cachedError + } + } else { + throw error + } + } + } + + let resolved = try await fetchBrowser() + storeResolved(resolved) + return resolved.snapshot + } + + static func shouldInvalidateCachedCookie(after error: Error) -> Bool { + switch error { + case OllamaUsageError.invalidCredentials, OllamaUsageError.notLoggedIn: + true + default: + false + } + } + + static func shouldTryBrowserCandidates(afterCachedFailure error: Error) -> Bool { + switch error { + case let OllamaUsageError.parseFailed(message): + message == "Missing Ollama usage data." + default: + false + } + } } struct OllamaAPIFetchStrategy: ProviderFetchStrategy { diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index f93e17305..68aac9840 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -102,41 +102,74 @@ public enum OllamaCookieImporter { allowFallbackBrowsers: Bool = false, logger: ((String) -> Void)? = nil) throws -> [SessionInfo] { - let log: (String) -> Void = { msg in logger?("[ollama-cookie] \(msg)") } var accessError: OllamaUsageError? let preferredOrder = preferredBrowsers.isEmpty ? ollamaCookieImportOrder : preferredBrowsers let preferredSources = self.cookieSources( from: preferredOrder, browserDetection: browserDetection, accessError: &accessError) - let preferredCandidates = self.collectSessionInfo( + let canUseFallback = allowFallbackBrowsers && !preferredBrowsers.isEmpty + return try self.importSessions( + preferredSources: preferredSources, + allowFallbackBrowsers: canUseFallback, + initialAccessError: accessError, + logger: logger, + loadFallbackSources: { accessError in + let fallbackOrder = ollamaCookieImportOrder.filter { !preferredBrowsers.contains($0) } + return self.cookieSources( + from: fallbackOrder, + browserDetection: browserDetection, + accessError: &accessError) + }, + loadSessions: self.loadSessions) + } + + static func importSessions( + preferredSources: [Browser], + allowFallbackBrowsers: Bool, + initialAccessError: OllamaUsageError? = nil, + logger: ((String) -> Void)? = nil, + loadFallbackSources: (inout OllamaUsageError?) -> [Browser], + loadSessions: (Browser, @escaping (String) -> Void) throws -> [SessionInfo]) throws -> [SessionInfo] + { + let log: (String) -> Void = { msg in logger?("[ollama-cookie] \(msg)") } + var accessError = initialAccessError + let preferredImport = self.collectSessionInfo( from: preferredSources, logger: log, - accessError: &accessError) + accessError: &accessError, + loadSessions: loadSessions) + var successfullyReadBrowsers = preferredImport.successfullyReadBrowsers do { - return try self.selectSessionInfos(from: preferredCandidates, logger: log) + return try self.selectSessionInfos(from: preferredImport.candidates, logger: log) } catch OllamaUsageError.noSessionCookie { - guard allowFallbackBrowsers, !preferredBrowsers.isEmpty else { - throw accessError ?? OllamaUsageError.noSessionCookie + guard allowFallbackBrowsers else { + throw self.surfacedAccessError( + accessError, + successfullyReadBrowsers: successfullyReadBrowsers) ?? OllamaUsageError.noSessionCookie } } - let fallbackOrder = ollamaCookieImportOrder.filter { !preferredBrowsers.contains($0) } - let fallbackSources = self.cookieSources( - from: fallbackOrder, - browserDetection: browserDetection, - accessError: &accessError) + var fallbackAccessError: OllamaUsageError? + let fallbackSources = loadFallbackSources(&fallbackAccessError) + fallbackAccessError = self.surfacedFallbackAccessError(fallbackAccessError) if !fallbackSources.isEmpty { log("No recognized Ollama session in preferred browsers; trying fallback import order") } - let fallbackCandidates = self.collectSessionInfo( + let fallbackImport = self.collectSessionInfo( from: fallbackSources, logger: log, - accessError: &accessError) + accessError: &fallbackAccessError, + suppressSafariAccessErrors: true, + loadSessions: loadSessions) + successfullyReadBrowsers.append(contentsOf: fallbackImport.successfullyReadBrowsers) + accessError = accessError ?? self.surfacedFallbackAccessError(fallbackAccessError) do { - return try self.selectSessionInfos(from: fallbackCandidates, logger: log) + return try self.selectSessionInfos(from: fallbackImport.candidates, logger: log) } catch OllamaUsageError.noSessionCookie { - throw accessError ?? OllamaUsageError.noSessionCookie + throw self.surfacedAccessError( + accessError, + successfullyReadBrowsers: successfullyReadBrowsers) ?? OllamaUsageError.noSessionCookie } } @@ -231,6 +264,20 @@ public enum OllamaCookieImporter { return .browserCookieDecryptionDenied(browser.displayName) } + static func surfacedAccessError( + _ error: OllamaUsageError?, + successfullyReadBrowsers: [Browser]) -> OllamaUsageError? + { + guard case .safariCookieAccessDenied = error else { return error } + guard successfullyReadBrowsers.contains(where: { $0 != .safari }) else { return error } + return nil + } + + static func surfacedFallbackAccessError(_ error: OllamaUsageError?) -> OllamaUsageError? { + guard case .safariCookieAccessDenied = error else { return error } + return nil + } + static func suppressedAccessError(for browser: Browser, now: Date = Date()) -> OllamaUsageError? { guard browser.usesKeychainForCookieDecryption else { return nil } if KeychainAccessGate.isDisabled { @@ -268,28 +315,45 @@ public enum OllamaCookieImporter { private static func collectSessionInfo( from browserSources: [Browser], logger: @escaping (String) -> Void, - accessError: inout OllamaUsageError?) -> [SessionInfo] + accessError: inout OllamaUsageError?, + suppressSafariAccessErrors: Bool = false, + loadSessions: (Browser, @escaping (String) -> Void) throws -> [SessionInfo]) + -> (candidates: [SessionInfo], successfullyReadBrowsers: [Browser]) { var candidates: [SessionInfo] = [] + var successfullyReadBrowsers: [Browser] = [] for browserSource in browserSources { do { - let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: logger) - for source in sources where !source.records.isEmpty { - let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - guard !cookies.isEmpty else { continue } - candidates.append(SessionInfo(cookies: cookies, sourceLabel: source.label)) - } + let sessions = try loadSessions(browserSource, logger) + successfullyReadBrowsers.append(browserSource) + candidates.append(contentsOf: sessions) } catch { BrowserCookieAccessGate.recordIfNeeded(error) - accessError = accessError ?? self.accessError(from: error) + let importedAccessError = self.accessError(from: error) + accessError = accessError ?? (suppressSafariAccessErrors + ? self.surfacedFallbackAccessError(importedAccessError) + : importedAccessError) logger("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } - return candidates + return (candidates, successfullyReadBrowsers) + } + + private static func loadSessions( + from browserSource: Browser, + logger: @escaping (String) -> Void) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: logger) + return sources.compactMap { source in + guard !source.records.isEmpty else { return nil } + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard !cookies.isEmpty else { return nil } + return SessionInfo(cookies: cookies, sourceLabel: source.label) + } } private static func containsRecognizedSessionCookie(in cookies: [HTTPCookie]) -> Bool { @@ -309,6 +373,12 @@ public struct OllamaUsageFetcher: Sendable { let sourceLabel: String } + struct ResolvedCookieFetch: Sendable { + let snapshot: OllamaUsageSnapshot + let cookieHeader: String + let sourceLabel: String + } + enum RetryableParseFailure: Error { case missingUsageData } @@ -340,9 +410,24 @@ public struct OllamaUsageFetcher: Sendable { manualCookieMode: Bool = false, logger: ((String) -> Void)? = nil, now: Date = Date()) async throws -> OllamaUsageSnapshot + { + try await self.fetchResolvedCookie( + cookieHeaderOverride: cookieHeaderOverride, + manualCookieMode: manualCookieMode, + logger: logger, + now: now).snapshot + } + + func fetchResolvedCookie( + cookieHeaderOverride: String? = nil, + cookieHeaderOverrideSourceLabel: String? = nil, + manualCookieMode: Bool = false, + logger: ((String) -> Void)? = nil, + now: Date = Date()) async throws -> ResolvedCookieFetch { let cookieCandidates = try await self.resolveCookieCandidates( override: cookieHeaderOverride, + overrideSourceLabel: cookieHeaderOverrideSourceLabel, manualCookieMode: manualCookieMode, logger: logger) return try await self.fetchUsingCookieCandidates( @@ -365,7 +450,7 @@ public struct OllamaUsageFetcher: Sendable { private func fetchUsingCookieCandidates( _ candidates: [CookieCandidate], logger: ((String) -> Void)?, - now: Date) async throws -> OllamaUsageSnapshot + now: Date) async throws -> ResolvedCookieFetch { do { return try await ProviderCandidateRetryRunner.run( @@ -392,7 +477,10 @@ public struct OllamaUsageFetcher: Sendable { self.logDiagnostics(responseInfo: responseInfo, diagnostics: diagnostics, logger: logger) } do { - return try Self.parseSnapshotForRetry(html: html, now: now) + return try ResolvedCookieFetch( + snapshot: Self.parseSnapshotForRetry(html: html, now: now), + cookieHeader: candidate.cookieHeader, + sourceLabel: candidate.sourceLabel) } catch { let surfacedError = Self.surfacedError(from: error) if let logger { @@ -437,6 +525,7 @@ public struct OllamaUsageFetcher: Sendable { private func resolveCookieCandidates( override: String?, + overrideSourceLabel: String? = nil, manualCookieMode: Bool, logger: ((String) -> Void)?) async throws -> [CookieCandidate] { @@ -445,7 +534,9 @@ public struct OllamaUsageFetcher: Sendable { manualCookieMode: manualCookieMode, logger: logger) { - return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")] + return [CookieCandidate( + cookieHeader: manualHeader, + sourceLabel: overrideSourceLabel ?? "manual cookie header")] } #if os(macOS) let sessions = try OllamaCookieImporter.importSessions( diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 5f66dce6f..4953c94b0 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -291,10 +291,11 @@ public enum ProviderBrowserCookieDefaults { #endif } - /// LongCat Auto imports only from Chrome by default to avoid prompting unrelated browser keychains. + /// LongCat Auto keeps Chrome first for existing users, then checks Firefox without adding + /// an unrelated browser Keychain prompt. public static var longcatCookieImportOrder: BrowserCookieImportOrder? { #if os(macOS) - [.chrome] + [.chrome, .firefox] #else nil #endif diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 5a664cd71..753856440 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -536,53 +536,6 @@ public struct UsageSnapshot: Codable, Sendable { return fallbackWindows + [primary] } - public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { - // This surface is labelled "Weekly progress", so prefer a real 7-day lane when one is - // available. Some providers publish model-specific weekly lanes in extraRateWindows. - if let weekly = self.mostConstrainedSwitcherWeeklyWindow() { - return weekly - } - - // Keep the existing provider-specific fallback for providers without a weekly allowance. - switch provider { - case .factory: - // Factory prefers secondary window - return self.secondary ?? self.primary - case .perplexity: - return self.automaticPerplexityWindow() - case .cursor: - // Cursor: fall back to on-demand budget when the included plan is exhausted (only in - // "show remaining" mode). The Auto/API lanes describe included quota consumption, - // not extra capacity, so they should not replace the remaining paid quota indicator. - if !showUsed, - let primary = self.primary, - primary.remainingPercent <= 0, - let providerCost = self.providerCost, - providerCost.limit > 0 - { - let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) - return RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: providerCost.resetsAt, - resetDescription: nil) - } - return self.primary ?? self.secondary - default: - return self.primary ?? self.secondary - } - } - - private func mostConstrainedSwitcherWeeklyWindow() -> RateWindow? { - let standardWindows = [self.primary, self.secondary, self.tertiary].compactMap(\.self) - let namedWindows = self.extraRateWindows? - .filter(\.usageKnown) - .map(\.window) ?? [] - return (standardWindows + namedWindows) - .filter { $0.windowMinutes == 7 * 24 * 60 } - .max { $0.usedPercent < $1.usedPercent } - } - public func accountEmail(for provider: UsageProvider) -> String? { self.identity(for: provider)?.accountEmail } diff --git a/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift b/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift new file mode 100644 index 000000000..b688f34c7 --- /dev/null +++ b/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift @@ -0,0 +1,60 @@ +extension UsageSnapshot { + public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { + // This surface is labelled "Weekly progress", so prefer a real 7-day lane when one is + // available. Some providers publish model-specific weekly lanes in extraRateWindows. + if let weekly = self.mostConstrainedSwitcherWeeklyWindow(for: provider) { + return weekly + } + + // Keep the existing provider-specific fallback for providers without a weekly allowance. + switch provider { + case .factory: + // Factory prefers secondary window + return self.secondary ?? self.primary + case .perplexity: + return self.automaticPerplexityWindow() + case .cursor: + // Cursor: fall back to on-demand budget when the included plan is exhausted (only in + // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, + // not extra capacity, so they should not replace the remaining paid quota indicator. + if !showUsed, + let primary = self.primary, + primary.remainingPercent <= 0, + let providerCost = self.providerCost, + providerCost.limit > 0 + { + let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: nil) + } + return self.primary ?? self.secondary + default: + return self.primary ?? self.secondary + } + } + + private func mostConstrainedSwitcherWeeklyWindow(for provider: UsageProvider) -> RateWindow? { + // Claude's Sonnet/Opus tertiary and model-scoped extras (Fable, Daily Routines) belong on + // the detail card. The overview switcher should track account Weekly so an exhausted + // carve-out does not empty the bar while Weekly still has quota left. + let standardWindows: [RateWindow] = switch provider { + case .claude: + [self.primary, self.secondary].compactMap(\.self) + default: + [self.primary, self.secondary, self.tertiary].compactMap(\.self) + } + let namedWindows = (self.extraRateWindows ?? []) + .filter(\.usageKnown) + .filter { named in + guard provider == .claude else { return true } + return !named.id.hasPrefix("claude-weekly-scoped-") && named.id != "claude-routines" + } + .map(\.window) + return (standardWindows + namedWindows) + .filter { $0.windowMinutes == 7 * 24 * 60 } + .max { $0.usedPercent < $1.usedPercent } + } +} diff --git a/Sources/CodexBarWidget/BurnDownWidgetProvider.swift b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift index 34f70535e..351936a43 100644 --- a/Sources/CodexBarWidget/BurnDownWidgetProvider.swift +++ b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift @@ -147,6 +147,7 @@ struct BurnDownState { } enum BurnDownRefreshSchedule { + private static let minimumInterval: TimeInterval = 5 * 60 private static let maximumInterval: TimeInterval = 30 * 60 static func nextRefresh( @@ -161,7 +162,12 @@ enum BurnDownRefreshSchedule { .filter { $0 > now } .min()? .addingTimeInterval(1) - return min(fallback, nextReset ?? fallback) + if let nextReset { + let target = min(fallback, nextReset) + let minimumDate = now.addingTimeInterval(self.minimumInterval) + return max(minimumDate, target) + } + return fallback } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index b1e0fa28b..8e4f33002 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -102,9 +102,15 @@ struct BrowserCookieOrderStatusStringTests { } @Test - func `longcat cookie imports default to chrome only`() { - #expect(ProviderDefaults.metadata[.longcat]?.browserCookieOrder == [.chrome]) - #expect(ProviderBrowserCookieDefaults.longcatCookieImportOrder == [.chrome]) + func `longcat cookie import order supports chrome and firefox`() { + let metadataOrder = ProviderDefaults.metadata[.longcat]?.browserCookieOrder + let defaultOrder = ProviderBrowserCookieDefaults.longcatCookieImportOrder + + #expect(metadataOrder == [.chrome, .firefox]) + #expect(defaultOrder == [.chrome, .firefox]) + #expect(defaultOrder?.first == .chrome) + #expect(defaultOrder?.contains(.firefox) == true) + #expect(defaultOrder?.contains(.safari) == false) } #endif } diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index d352d7444..123748fd8 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -566,6 +566,93 @@ struct CLISnapshotTests { #expect(pace.summary == "On pace | Expected 60% used | Lasts until reset") } + @Test + func `Kimi routes inverted quota windows to CLI pace and JSON metadata`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 30, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: "weekly"), + secondary: .init( + usedPercent: 10, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: "rate limit"), + tertiary: nil, + updatedAt: now) + + let pace = try #require(CLIRenderer.providerPacePayload(provider: .kimi, snapshot: snapshot, now: now)) + #expect(pace.primary?.expectedUsedPercent == 43) + #expect(pace.primary?.summary == "13% in reserve | Expected 43% used | Lasts until reset") + #expect(pace.secondary?.expectedUsedPercent == 20) + #expect(pace.secondary?.summary == "10% in reserve | Expected 20% used | Lasts until reset") + + let output = CLIRenderer.renderText( + provider: .kimi, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Kimi", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + #expect(output.split(separator: "\n").count(where: { $0.contains("Pace:") }) == 2) + + let payload = ProviderPayload( + provider: .kimi, + account: nil, + version: nil, + source: "Kimi Code API key", + status: nil, + usage: snapshot, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: pace) + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let usage = try #require(root["usage"] as? [String: Any]) + let primary = try #require(usage["primary"] as? [String: Any]) + let secondary = try #require(usage["secondary"] as? [String: Any]) + #expect(primary["windowMinutes"] as? Int == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(secondary["windowMinutes"] as? Int == KimiProviderDescriptor.sessionWindowMinutes) + let encodedPace = try #require(root["pace"] as? [String: Any]) + #expect(encodedPace["primary"] != nil) + #expect(encodedPace["secondary"] != nil) + } + + @Test + func `Kimi CLI pace rejects missing and unsupported window durations`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + for duration: Int? in [nil, 24 * 60, 30 * 24 * 60] { + let window = RateWindow( + usedPercent: 25, + windowMinutes: duration, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil) + let snapshots = [ + UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + updatedAt: now), + UsageSnapshot( + primary: nil, + secondary: window, + tertiary: nil, + updatedAt: now), + ] + + for snapshot in snapshots { + #expect(CLIRenderer.providerPacePayload(provider: .kimi, snapshot: snapshot, now: now) == nil) + } + } + } + @Test func `renders Ollama weekly pace line when weekly window has reset`() { let now = Date() diff --git a/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift b/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift new file mode 100644 index 000000000..050060c64 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing + +/// The Claude CLI renders /usage as a redrawing TUI, so a half-painted capture frame can drop a +/// character from "Current week (all models)" (-> "all modls"). These cover the shapes that matters: +/// the garbled copy must not become a bogus second weekly row, an exact copy must always win the +/// Weekly value regardless of order, the Weekly limit must still be recovered when only the garbled +/// copy survived, genuine model-scoped rows must be preserved, and the edit-distance boundary is +/// pinned so heavier corruption stays a (visible) scoped row rather than being silently absorbed. +struct ClaudeAllModelsWeeklyDuplicateTests { + @Test + func `garbled all-models copy does not duplicate the weekly row`() throws { + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all models) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 67% used + Resets Jul 24 at 1:59pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + let titles = snap.extraRateWindows.map(\.title) + #expect(titles == ["Fable only"], "unexpected scoped weekly rows: \(titles)") + } + + @Test + func `exact all-models row wins over an earlier garbled duplicate`() throws { + // Garbled 67% copy appears BEFORE the clean 66% copy; the Weekly value and reset must still + // come from the exact row (34% left, 2pm), not the corrupted one that happens to be first. + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 67% used + Resets Jul 24 at 1:59pm (Asia/Seoul) + + Current week (all models) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.secondaryResetDescription == "Resets Jul 24 at 2pm (Asia/Seoul)") + #expect(snap.extraRateWindows.isEmpty, "garbled copy leaked as a scoped row") + } + + @Test + func `garbled all-models line still populates the weekly limit`() throws { + // Only the corrupted all-models label survived this capture; the Weekly quota must be + // recovered from it rather than dropped, and it must not appear as a scoped row. + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.secondaryResetDescription == "Resets Jul 24 at 2pm (Asia/Seoul)") + let titles = snap.extraRateWindows.map(\.title) + #expect(titles == ["Fable only"], "unexpected scoped weekly rows: \(titles)") + } + + @Test + func `single-character insertion is treated as all-models`() throws { + // A dropped-then-doubled render ("all modelss", edit distance 1) is still the aggregate row. + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modelss) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.extraRateWindows.map(\.title) == ["Fable only"]) + } + + @Test + func `genuine scoped model is preserved and leaves the weekly limit unset`() throws { + // No all-models row at all: the Weekly limit must be nil and the scoped Haiku row kept. + // (Sonnet/Opus are folded into opusPercentLeft, so Haiku is used to exercise the extra-row path.) + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (Haiku) + █▌ 10% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == nil) + #expect(snap.extraRateWindows.map(\.title) == ["Haiku only"]) + } + + @Test + func `heavier corruption stays a visible scoped row`() throws { + // "almdls" is edit distance 3 from "allmodels" — beyond the tolerance. We intentionally do + // NOT absorb it into Weekly (that would risk swallowing a real future scoped model); instead + // it stays visible as its own row so nothing is silently dropped. + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (almdls) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == nil) + #expect(snap.extraRateWindows.map(\.title) == ["almdls only"]) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 93ae29074..72b64df37 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -202,6 +202,27 @@ struct ClaudeOAuthTests { #expect(snap.extraRateWindows.contains { $0.id.contains("all-models") } == false) } + @Test + func `orders O auth scoped weekly windows before daily routines`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day": { "utilization": 30, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2025-12-31T00:00:00.000Z", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.map(\.title) == ["Fable only", "Daily Routines"]) + } + @Test func `ignores weekly scoped limit without a model display name`() throws { let json = """ @@ -236,7 +257,7 @@ struct ClaudeOAuthTests { } @Test - func `maps O auth null cowork as zero routines window`() throws { + func `omits routines window when O auth cowork is null`() throws { let json = """ { "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, @@ -245,7 +266,7 @@ struct ClaudeOAuthTests { } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(snap.extraRateWindows.contains { $0.id == "claude-routines" } == false) #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) } diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index 7781470f0..ac9704029 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -4,47 +4,46 @@ import Testing struct ClaudeWebUsageExtraWindowTests { @Test - func `parses claude web API sonnet usage response`() throws { + func `omits routines window when claude web API cowork is null`() throws { let json = """ { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": null } """ - let data = Data(json.utf8) - let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.opusPercentUsed == 6) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + #expect(parsed.extraRateWindows.contains { $0.id == "claude-routines" } == false) + #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) } @Test - func `ignores merged claude web API omelette usage window`() throws { + func `parses claude web API sonnet usage response`() throws { let json = """ { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, - "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } + "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } } """ let data = Data(json.utf8) let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.count == 1) - #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) + #expect(parsed.opusPercentUsed == 6) } @Test - func `parses claude web API cowork null as zero routines window`() throws { + func `ignores merged claude web API omelette usage window`() throws { let json = """ { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, - "seven_day_cowork": null + "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } } """ let data = Data(json.utf8) let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(parsed.extraRateWindows.count == 1) #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) } @Test @@ -102,4 +101,52 @@ struct ClaudeWebUsageExtraWindowTests { #expect(parsed.weeklyPercentUsed == 41) #expect(parsed.extraRateWindows.contains { $0.id.contains("all-models") } == false) } + + @Test + func `orders scoped weekly windows before daily routines`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2026-07-03T00:30:00.440902+00:00" }, + "seven_day": { "utilization": 20, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + #expect(parsed.extraRateWindows.map(\.title) == ["Fable only", "Daily Routines"]) + } + + @Test + func `keeps multiple scoped weekly windows in payload order before routines`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2026-07-03T00:30:00.440902+00:00" }, + "seven_day": { "utilization": 20, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 12, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Opus" }, "surface": null }, + "is_active": false + }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + #expect(parsed.extraRateWindows.map(\.title) == ["Opus only", "Fable only", "Daily Routines"]) + } } diff --git a/Tests/CodexBarTests/CodexBarConfigHooksTests.swift b/Tests/CodexBarTests/CodexBarConfigHooksTests.swift new file mode 100644 index 000000000..cc4158bff --- /dev/null +++ b/Tests/CodexBarTests/CodexBarConfigHooksTests.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexBarConfigHooksTests { + @Test + func `hooks survive config round trip`() throws { + let hooks = HooksConfig( + enabled: true, + events: [ + HookRule( + id: "quota-low", + event: .quotaLow, + provider: "codex", + threshold: 0.9, + executable: "/usr/bin/true", + timeoutSeconds: 30), + ]) + let config = CodexBarConfig( + providers: [ProviderConfig(id: .codex, enabled: true)], + hooks: hooks) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + + #expect(decoded.hooks == hooks) + } +} diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index b0d69ebd9..6ba82c730 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -824,35 +824,6 @@ struct CodexBarWidgetProviderTests { #expect(axis.start == effectiveReset.addingTimeInterval(-5 * 60 * 60)) } - @Test - func `burn down refreshes immediately after the earliest future reset`() { - let now = Date(timeIntervalSince1970: 1_700_000_000) - let snapshot = Self.burnSnapshot( - provider: .codex, - primaryUsed: 20, - secondaryUsed: 30, - primaryReset: now.addingTimeInterval(60), - secondaryReset: now.addingTimeInterval(120)) - - #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) - == now.addingTimeInterval(61)) - } - - @Test - func `burn down refresh ignores past resets and unrelated provider entries`() { - let now = Date(timeIntervalSince1970: 1_700_000_000) - let snapshot = Self.burnSnapshot( - provider: .claude, - primaryUsed: 20, - secondaryUsed: 30, - primaryReset: now.addingTimeInterval(-60), - secondaryReset: now.addingTimeInterval(-30)) - let fallback = now.addingTimeInterval(30 * 60) - - #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .claude, now: now) == fallback) - #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) == fallback) - } - private static func burnSnapshot( provider: UsageProvider, primaryUsed: Double?, @@ -888,6 +859,51 @@ struct CodexBarWidgetProviderTests { } } +extension CodexBarWidgetProviderTests { + @Test + func `burn down refreshes immediately after the earliest future reset`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(360), + secondaryReset: now.addingTimeInterval(420)) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) + == now.addingTimeInterval(361)) + } + + @Test + func `burn down refresh clamps to minimum interval`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(60), + secondaryReset: now.addingTimeInterval(120)) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) + == now.addingTimeInterval(300)) + } + + @Test + func `burn down refresh ignores past resets and unrelated provider entries`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .claude, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(-60), + secondaryReset: now.addingTimeInterval(-30)) + let fallback = now.addingTimeInterval(30 * 60) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .claude, now: now) == fallback) + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) == fallback) + } +} + extension CodexBarWidgetProviderTests { @Test func `provider choice supports Cursor`() { diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 39522e91b..7585b328d 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -647,6 +647,60 @@ struct KimiUsageResponseParsingTests { #expect(snapshot.rateLimit?.used == nil) #expect(snapshot.rateLimit?.remaining == "99") #expect(snapshot.rateLimit?.resetTime == "2026-01-06T13:33:02Z") + #expect(snapshot.toUsageSnapshot().primary?.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(snapshot.toUsageSnapshot().secondary?.windowMinutes == 300) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Rate: 1/100 per 5 hours") + } + + @Test + func `derives rate window duration from API units`() throws { + let json = """ + { + "usage": {"limit": 1000, "used": 40, "remaining": 960}, + "limits": [ + { + "window": {"duration": 2, "timeUnit": "TIME_UNIT_HOUR"}, + "detail": {"limit": 100, "used": 25, "remaining": 75} + } + ] + } + """ + + let usage = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)).toUsageSnapshot() + + #expect(usage.secondary?.windowMinutes == 120) + #expect(usage.secondary?.resetDescription == "Rate: 25/100 per 2 hours") + } + + @Test + func `converts supported window units and rejects invalid durations`() { + #expect(KimiWindow(duration: 300, timeUnit: "TIME_UNIT_MINUTE").durationMinutes == 300) + #expect(KimiWindow(duration: 5, timeUnit: "TIME_UNIT_HOUR").durationMinutes == 300) + #expect(KimiWindow(duration: 7, timeUnit: "TIME_UNIT_DAY").durationMinutes == 10080) + #expect(KimiWindow(duration: 0, timeUnit: "TIME_UNIT_HOUR").durationMinutes == nil) + #expect(KimiWindow(duration: -1, timeUnit: "TIME_UNIT_DAY").durationMinutes == nil) + #expect(KimiWindow(duration: Int.max, timeUnit: "TIME_UNIT_HOUR").durationMinutes == nil) + #expect(KimiWindow(duration: 5, timeUnit: "TIME_UNIT_UNKNOWN").durationMinutes == nil) + } + + @Test + func `unknown rate window unit does not fabricate a duration`() throws { + let json = """ + { + "usage": {"limit": 1000, "used": 40, "remaining": 960}, + "limits": [ + { + "window": {"duration": 5, "timeUnit": "TIME_UNIT_UNKNOWN"}, + "detail": {"limit": 100, "used": 25, "remaining": 75} + } + ] + } + """ + + let usage = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)).toUsageSnapshot() + + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Rate: 25/100") } @Test @@ -746,6 +800,7 @@ struct KimiUsageResponseParsingTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) #expect(usage.secondary?.usedPercent == 25) #expect(usage.extraRateWindows == nil) #expect(elapsed < .milliseconds(250), "Subscription enrichment outlived its total budget: \(elapsed)") @@ -905,7 +960,7 @@ struct KimiUsageResponseParsingTests { struct KimiUsageSnapshotConversionTests { @Test - func `converts to usage snapshot with both windows`() { + func `converts to usage snapshot with both windows`() throws { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -925,17 +980,19 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() - #expect(usageSnapshot.primary != nil) + let primary = try #require(usageSnapshot.primary) let weeklyExpected = 375.0 / 2048.0 * 100.0 - #expect(abs((usageSnapshot.primary?.usedPercent ?? 0.0) - weeklyExpected) < 0.01) - #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests") - #expect(usageSnapshot.primary?.windowMinutes == nil) + #expect(abs(primary.usedPercent - weeklyExpected) < 0.01) + #expect(primary.resetDescription == "375/2048 requests") + #expect(primary.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(KimiProviderDescriptor.descriptor.pace.supportsResetWindowPace(window: primary, now: now)) - #expect(usageSnapshot.secondary != nil) + let secondary = try #require(usageSnapshot.secondary) let rateExpected = 200.0 / 200.0 * 100.0 - #expect(abs((usageSnapshot.secondary?.usedPercent ?? 0.0) - rateExpected) < 0.01) - #expect(usageSnapshot.secondary?.windowMinutes == 300) // 5 hours - #expect(usageSnapshot.secondary?.resetDescription == "Rate: 200/200 per 5 hours") + #expect(abs(secondary.usedPercent - rateExpected) < 0.01) + #expect(secondary.windowMinutes == KimiProviderDescriptor.sessionWindowMinutes) + #expect(secondary.resetDescription == "Rate: 200/200 per 5 hours") + #expect(!KimiProviderDescriptor.descriptor.pace.supportsResetWindowPace(window: secondary, now: now)) #expect(usageSnapshot.tertiary == nil) #expect(usageSnapshot.updatedAt == now) @@ -966,6 +1023,7 @@ struct KimiUsageSnapshotConversionTests { #expect(monthly.id == "kimi-monthly") #expect(monthly.title == "Monthly") #expect(monthly.window.usedPercent == 100) + #expect(monthly.window.windowMinutes == nil) #expect(monthly.window.resetsAt == Self.date("2026-07-23T00:00:00Z")) } @@ -1103,6 +1161,84 @@ struct KimiUsageSnapshotConversionTests { #expect(usageSnapshot.secondary == nil) } + @Test + func `malformed limits do not fabricate pace metadata`() { + let now = Date() + let weekly = KimiUsageDetail(limit: "100", used: "25", remaining: "75", resetTime: nil) + let invalid = KimiUsageDetail(limit: "invalid", used: "5", remaining: "15", resetTime: nil) + let zero = KimiUsageDetail(limit: "0", used: "0", remaining: "0", resetTime: nil) + + #expect(KimiUsageSnapshot(weekly: invalid, rateLimit: nil, updatedAt: now).toUsageSnapshot().primary == nil) + #expect(KimiUsageSnapshot(weekly: zero, rateLimit: nil, updatedAt: now).toUsageSnapshot().primary == nil) + #expect(KimiUsageSnapshot(weekly: weekly, rateLimit: invalid, updatedAt: now) + .toUsageSnapshot().secondary == nil) + } + + @Test + func `derives invalid or missing used counts from valid remaining counts`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "invalid", remaining: "75", resetTime: nil), + rateLimit: KimiUsageDetail(limit: "20", used: nil, remaining: "15", resetTime: nil), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + #expect(try #require(usage.primary).usedPercent == 25) + #expect(try #require(usage.secondary).usedPercent == 25) + } + + @Test + func `over quota used count wins over contradictory remaining`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "125", remaining: "25", resetTime: nil), + rateLimit: nil, + updatedAt: now) + + let primary = try #require(snapshot.toUsageSnapshot().primary) + #expect(primary.usedPercent == 100) + #expect(primary.resetDescription == "125/100 requests") + #expect(primary.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + } + + @Test + func `negative used count falls back to valid remaining`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "-1", remaining: "75", resetTime: nil), + rateLimit: nil, + updatedAt: now) + + #expect(try #require(snapshot.toUsageSnapshot().primary).usedPercent == 25) + } + + @Test + func `invalid remaining does not synthesize a quota`() { + let now = Date() + for remaining in ["-1", "101", "invalid"] { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: nil, remaining: remaining, resetTime: nil), + rateLimit: nil, + updatedAt: now) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().primary?.windowMinutes == nil) + } + } + + @Test + func `missing counters preserve gauge without pace metadata`() throws { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: nil, remaining: nil, resetTime: nil), + rateLimit: nil, + updatedAt: Date()) + + let primary = try #require(snapshot.toUsageSnapshot().primary) + #expect(primary.usedPercent == 0) + #expect(primary.windowMinutes == nil) + #expect(primary.resetDescription == "0/100 requests") + } + @Test func `handles zero values correctly`() { let now = Date() diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift index 7a7d2572c..1d4e2f7b9 100644 --- a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -54,6 +54,31 @@ struct LocalizationBundleCacheTests { #expect(bundle.bundleURL.lastPathComponent == "en.lproj") } + @Test + func `format locale follows the resolved resource bundle`() { + let english = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + codexBarLocalizedResourceLocale() + } + #expect(english.language.languageCode?.identifier == "en") + + let fallback = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedResourceLocale() + } + #expect(fallback.language.languageCode?.identifier == "en") + } + + @Test + func `resource locale expands English stringsdict singular forms`() { + let rendered = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [1, 1]) + } + + #expect(rendered == "≈1 full 5h window of weekly left · 1 window until reset") + } + @Test func `resolution survives an explicit cache reset`() { let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index 1bd43ddd0..fc3babad7 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -129,6 +129,29 @@ struct MenuBarLayoutRendererTests { #expect(bounds.height <= 22) } + @Test + func `stacked titles apply a vertical centering offset`() throws { + let renderer = MenuBarLayoutRenderer() + let stacked = renderer.render( + layout: MenuBarLayout(lines: [ + [.percent(window: .automatic)], + [.resetCountdown], + ]), + data: self.data(), + icon: nil, + options: self.options()) + let resetIndex = (stacked.attributedTitle.string as NSString).range(of: "in 2h").location + let singleLine = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .automatic), .resetCountdown]]), + data: self.data(), + icon: nil, + options: self.options()) + + #expect(try #require(self.baselineOffset(in: stacked.attributedTitle, at: 0)) == -3) + #expect(try #require(self.baselineOffset(in: stacked.attributedTitle, at: resetIndex)) == -3) + #expect(self.baselineOffset(in: singleLine.attributedTitle, at: 0) == nil) + } + @Test func `two line icon uses compact paragraph metrics`() { let renderer = MenuBarLayoutRenderer() @@ -303,4 +326,15 @@ struct MenuBarLayoutRendererTests { } return try totalBrightness / CGFloat(#require(visiblePixelCount > 0 ? visiblePixelCount : nil)) } + + private func baselineOffset(in title: NSAttributedString, at index: Int) -> CGFloat? { + let value = title.attribute(.baselineOffset, at: index, effectiveRange: nil) + if let value = value as? CGFloat { + return value + } + if let value = value as? NSNumber { + return CGFloat(truncating: value) + } + return nil + } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 09e52e3dd..eec9f3308 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -102,18 +102,18 @@ struct OverviewMenuCardVisibilityTests { struct ProviderInlineDashboardModelTests { @Test - func `kimi model orders rate limit before weekly quota`() throws { + func `kimi model orders rate limit before weekly quota and shows pace`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.kimi]) let snapshot = UsageSnapshot( primary: RateWindow( usedPercent: 18.3, - windowMinutes: nil, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), resetDescription: "375/2048 requests"), secondary: RateWindow( usedPercent: 9.5, - windowMinutes: 300, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, resetsAt: now.addingTimeInterval(4 * 60 * 60), resetDescription: "Rate: 19/200 per 5 hours"), updatedAt: now) @@ -140,6 +140,9 @@ struct ProviderInlineDashboardModelTests { #expect(model.metrics.map(\.id) == ["secondary", "primary"]) #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"]) + #expect(model.metrics.map(\.detailLeftText) == ["11% in reserve", "25% in reserve"]) + #expect(model.metrics.map(\.detailRightText) == ["Lasts until reset", "Lasts until reset"]) + #expect(model.metrics.allSatisfy { $0.pacePercent != nil }) } @Test diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index 9c209b4b3..55aba9b4c 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -84,6 +84,159 @@ struct OllamaUsageFetcherRetryMappingTests { #expect(strategy.shouldFallback(on: OllamaUsageError.parseFailed("missing"), context: context)) } + @Test + func `automatic web fetch reuses validated cached cookie without browser import`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { entry in + events.append("cache:\(entry.sourceLabel)") + return Self.makeSnapshot(sessionUsedPercent: 12) + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 99), + cookieHeader: "session=browser", + sourceLabel: "Browser") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + + #expect(snapshot.sessionUsedPercent == 12) + #expect(events == ["cache:Chrome"]) + } + + @Test + func `automatic web fetch keeps cached cookie on offline failure`() async { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + await #expect(throws: URLError.self) { + _ = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in throw URLError(.notConnectedToInternet) }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 99), + cookieHeader: "session=browser", + sourceLabel: "Browser") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + } + + #expect(events.isEmpty) + } + + @Test + func `automatic web fetch replaces cached cookie only after authentication failure`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=expired", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.invalidCredentials + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 34), + cookieHeader: "session=fresh", + sourceLabel: "Brave") + }, + clearCached: { entry in events.append("clear:\(entry.sourceLabel)") }, + storeResolved: { resolved in events.append("store:\(resolved.sourceLabel)") }) + + #expect(snapshot.sessionUsedPercent == 34) + #expect(events == ["cache", "clear:Chrome", "browser", "store:Brave"]) + } + + @Test + func `cached cookie invalidation excludes network and parse errors`() { + #expect(OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.invalidCredentials)) + #expect(OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.notLoggedIn)) + #expect(!OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: URLError(.notConnectedToInternet))) + #expect(!OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.parseFailed("changed page"))) + } + + @Test + func `cached session missing usage tries another browser without clearing cache`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.parseFailed("Missing Ollama usage data.") + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 56), + cookieHeader: "session=browser", + sourceLabel: "Brave") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { resolved in events.append("store:\(resolved.sourceLabel)") }) + + #expect(snapshot.sessionUsedPercent == 56) + #expect(events == ["cache", "browser", "store:Brave"]) + } + + @Test + func `failed browser fallback preserves cached missing usage error`() async { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + do { + _ = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.parseFailed("Missing Ollama usage data.") + }, + fetchBrowser: { + events.append("browser") + throw OllamaUsageError.noSessionCookie + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + Issue.record("Expected cached parse failure") + } catch let OllamaUsageError.parseFailed(message) { + #expect(message == "Missing Ollama usage data.") + } catch { + Issue.record("Expected cached parse failure, got \(error)") + } + + #expect(events == ["cache", "browser"]) + } + @Test(arguments: [401, 403]) func `api fetch sends bearer token and rejects unauthorized key`(statusCode: Int) async throws { let url = try #require(URL(string: "https://ollama.com/api/web_search")) @@ -516,6 +669,17 @@ struct OllamaUsageFetcherRetryMappingTests { headerFields: ["Content-Type": "text/html"])! return (response, Data(body.utf8)) } + + private static func makeSnapshot(sessionUsedPercent: Double) -> OllamaUsageSnapshot { + OllamaUsageSnapshot( + planName: nil, + accountEmail: nil, + sessionUsedPercent: sessionUsedPercent, + weeklyUsedPercent: nil, + sessionResetsAt: nil, + weeklyResetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 200)) + } } private final class OllamaSessionFinishRecorder: @unchecked Sendable { diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 20de01569..9e3ade6d9 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -5,6 +5,7 @@ import Testing import SweetCookieKit #endif +@Suite(.serialized) struct OllamaUsageFetcherTests { @Test func `session authentication errors point to current recovery page`() { @@ -160,6 +161,129 @@ struct OllamaUsageFetcherTests { #expect(ambiguous == nil) } + @Test + func `multi browser import skips safari access error after chrome was read`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + var attemptedBrowsers: [Browser] = [] + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari] }, + loadSessions: { browser, _ in + attemptedBrowsers.append(browser) + if browser == .safari { + throw BrowserCookieError.accessDenied( + browser: .safari, + details: "Full Disk Access denied") + } + return [] + }) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + #expect(attemptedBrowsers == [.chrome, .safari]) + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func `automatic fallback skips safari access error when preferred browser is unavailable`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + var attemptedBrowsers: [Browser] = [] + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari] }, + loadSessions: { browser, _ in + attemptedBrowsers.append(browser) + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Full Disk Access denied") + }) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + #expect(attemptedBrowsers == [.safari]) + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func `automatic fallback keeps non safari access error after safari denial`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari, .brave] }, + loadSessions: { browser, _ in + if browser == .chrome { + return [] + } + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Access denied") + }) + Issue.record("Expected Brave Keychain denial") + } catch let OllamaUsageError.browserCookieDecryptionDenied(browserName) { + #expect(browserName == "Brave") + } catch { + Issue.record("Expected Brave Keychain denial, got \(error)") + } + } + + @Test + func `fallback browser gates stay lazy when chrome has a session`() throws { + var loadedFallbackSources = false + let sessions = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in + loadedFallbackSources = true + return [.safari] + }, + loadSessions: { browser, _ in + #expect(browser == .chrome) + return [OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "session", value: "auth")], + sourceLabel: "Chrome Profile")] + }) + + #expect(sessions.map(\.sourceLabel) == ["Chrome Profile"]) + #expect(!loadedFallbackSources) + } + + @Test + func `explicit safari import surfaces safari access error`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.safari], + allowFallbackBrowsers: false, + loadFallbackSources: { _ in [] }, + loadSessions: { browser, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Full Disk Access denied") + }) + Issue.record("Expected Safari Full Disk Access error") + } catch OllamaUsageError.safariCookieAccessDenied { + // expected + } catch { + Issue.record("Expected Safari Full Disk Access error, got \(error)") + } + } + @Test func `cookie cooldown maps only the browser that was denied`() { BrowserCookieAccessGate.resetForTesting() diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index 4d34c3175..c63835b4b 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -7,7 +7,7 @@ struct ProviderPaceCapabilityTests { private static let monthlyWindowSentinelMinutes = 30 * 24 * 60 @Test - func `descriptor pace capabilities preserve the legacy provider mapping`() { + func `descriptor pace capabilities match the supported provider mapping`() { let now = Date(timeIntervalSince1970: 1_750_000_000) let fixtures: [RateWindow] = [ Self.window(minutes: nil, resetsAt: nil), @@ -30,12 +30,12 @@ struct ProviderPaceCapabilityTests { let capability = ProviderDescriptorRegistry.descriptor(for: provider).pace for window in fixtures { let actualResetWindowPace = capability.supportsResetWindowPace(window: window, now: now) - let legacyResetWindowPace = Self.legacySupportsResetWindowPace( + let expectedResetWindowPace = Self.expectedSupportsResetWindowPace( provider: provider, window: window, now: now) #expect( - actualResetWindowPace == legacyResetWindowPace, + actualResetWindowPace == expectedResetWindowPace, "Reset-window pace changed for \(provider.rawValue), window=\(String(describing: window)).") let actualMonthlyInference = capability.usesInferredMonthlyDuration(window: window) @@ -57,8 +57,8 @@ struct ProviderPaceCapabilityTests { resetDescription: nil) } - /// Snapshot of the switches removed from UsageMenuCardView.Model. - private static func legacySupportsResetWindowPace( + /// Expected provider-specific reset-window behavior, including newly declared capabilities. + private static func expectedSupportsResetWindowPace( provider: UsageProvider, window: RateWindow, now: Date) -> Bool @@ -77,6 +77,8 @@ struct ProviderPaceCapabilityTests { return windowMinutes > 0 && timeUntilReset > 0 && timeUntilReset <= TimeInterval(windowMinutes) * 60 + case .kimi: + return window.windowMinutes == self.weeklyWindowMinutes case .alibaba, .alibabatokenplan, .doubao, .opencodego: return window.windowMinutes == self.monthlyWindowSentinelMinutes default: diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 731304923..dde17045a 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -92,6 +92,28 @@ struct ProviderSettingsDescriptorTests { } } + @Test + func `ollama automatic cookie source exposes validated refresh action`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-ollama-refresh") + let context = fixture.settingsContext(provider: .ollama) + let pickers = OllamaProviderImplementation().settingsPickers(context: context) + let picker = try #require(pickers.first { $0.id == "ollama-cookie-source" }) + let action = try #require(picker.trailingActions.first) + + #expect(action.id == "ollama-reimport-cookie") + #expect(action.title == "Refresh") + #expect(action.isVisible?() == true) + + fixture.settings.ollamaCookieSource = .manual + #expect(action.isVisible?() == false) + #expect(picker.trailingText?() == nil) + + fixture.settings.ollamaCookieSource = .auto + fixture.settings.ollamaUsageDataSource = .api + #expect(action.isVisible?() == false) + #expect(picker.trailingText?() == nil) + } + @Test func `open code go cookie refresh rejects local fallback cookie`() async throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-opencodego-validation") diff --git a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift index 5f95bb420..293942785 100644 --- a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift +++ b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift @@ -164,7 +164,7 @@ struct SessionEquivalentForecastTests { let now = Date(timeIntervalSince1970: 1_900_000_000) let session = RateWindow( usedPercent: 20, - windowMinutes: 300, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, resetsAt: now.addingTimeInterval(3600), resetDescription: nil) let burn = SessionEquivalentBurnEstimate(medianWeeklyPercentPerWindow: 10, sampleCount: 3) @@ -705,6 +705,56 @@ extension SessionEquivalentForecastTests { #expect(windows.weeklyWindowID == "zai-named-weekly") } + @MainActor + @Test + func `Kimi resolves its inverted primary and secondary windows by duration`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + let weekly = RateWindow( + usedPercent: 40, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil) + let session = RateWindow( + usedPercent: 20, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil) + let snapshot = UsageSnapshot( + primary: weekly, + secondary: session, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "kimi-code-7d", + title: "Code 7-day", + window: RateWindow( + usedPercent: 15, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let windows = try #require(store.sessionEquivalentWindows(provider: .kimi, snapshot: snapshot)) + #expect(windows.session.windowMinutes == KimiProviderDescriptor.sessionWindowMinutes) + #expect(windows.weekly.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(windows.weekly.usedPercent == 40) + + let forecast = try #require(SessionEquivalentForecast.make( + sessionWindow: windows.session, + weeklyWindow: windows.weekly, + burnEstimate: SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: 10, + sampleCount: 3), + now: now, + workDays: nil)) + #expect(forecast.estimatedWindowsToExhaustWeekly == 6) + #expect(forecast.windowsUntilReset == 14) + let detail = UsagePaceText.sessionEquivalentDetail(forecast: forecast) + #expect(detail.verdictText == "Weekly can run out ≈8 windows early") + } + @MainActor @Test func `generic named windows require the same quota family`() async throws { diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift index b609ad751..406aff061 100644 --- a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -14,7 +14,12 @@ struct SpendDashboardClockRolloverTests { let configuration = Self.configuration let initialInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) let rolloverInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) + // Keep selectDays persistence out of UserDefaults.standard so later suites that + // construct SpendDashboardController with the default store still get the 30-day window. + let defaults = try Self.isolatedDefaults(suiteName: "SpendDashboardClockRolloverTests-window") + defer { defaults.removePersistentDomain(forName: "SpendDashboardClockRolloverTests-window") } let controller = SpendDashboardController( + userDefaults: defaults, requestBuilder: { mode in SpendDashboardLoadRequest( configuration: configuration, @@ -58,7 +63,10 @@ struct SpendDashboardClockRolloverTests { let staleInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) let freshInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) let gate = SpendDashboardRolloverGate() + let defaults = try Self.isolatedDefaults(suiteName: "SpendDashboardClockRolloverTests-inflight") + defer { defaults.removePersistentDomain(forName: "SpendDashboardClockRolloverTests-inflight") } let controller = SpendDashboardController( + userDefaults: defaults, requestBuilder: { mode in SpendDashboardLoadRequest( configuration: configuration, @@ -72,7 +80,6 @@ struct SpendDashboardClockRolloverTests { await gate.load(request) }, nowProvider: { clock.value }) - controller.update(configuration: configuration) await Self.waitForPendingCount(1, gate: gate) @@ -94,6 +101,12 @@ struct SpendDashboardClockRolloverTests { providerIDs: [UsageProvider.codex.rawValue], codexAccountIdentities: ["rollover"]) + private static func isolatedDefaults(suiteName: String) throws -> UserDefaults { + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + private static func input(day: String, cost: Double, updatedAt: Date) -> SpendDashboardModel.ProviderInput { let entry = CostUsageDailyReport.Entry( date: day, diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 256a0b48d..e1259d5ff 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct SpendDashboardControllerTests { @Test func `empty codex history loads as successful inactive source`() async { @@ -288,9 +289,7 @@ struct SpendDashboardControllerTests { let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot store._setTokenSnapshotForTesting(snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) let baselineConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: baselineConfiguration) @@ -408,9 +407,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) @@ -431,9 +428,7 @@ struct SpendDashboardControllerTests { #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = Self.dashboardController(settings: settings, store: store) reopenedController.update(configuration: replacementConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) @@ -487,14 +482,7 @@ struct SpendDashboardControllerTests { store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) store._test_providerRefreshOverride = { _ in } - let controllerDefaultsName = "SpendDashboardControllerTests-token-account-owner-view" - let controllerDefaults = try #require(UserDefaults(suiteName: controllerDefaultsName)) - controllerDefaults.removePersistentDomain(forName: controllerDefaultsName) - let controller = SpendDashboardController( - userDefaults: controllerDefaults, - requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) controller.update(configuration: selectedBackupConfiguration) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -532,9 +520,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -562,9 +548,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) await Self.waitUntil { !controller.isRefreshing } @@ -631,9 +615,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = Self.dashboardController(settings: settings, store: store) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 5) @@ -649,9 +631,7 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) #expect(controller.failedSourceCount == 1) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = Self.dashboardController(settings: settings, store: store) reopenedController.update(configuration: reenabledConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) @@ -773,6 +753,23 @@ struct SpendDashboardControllerTests { #expect(controller.selectedDays == 30) } + private static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) + + private static func dashboardController( + settings: SettingsStore, + store: UsageStore) -> SpendDashboardController + { + SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: Self.fixtureNow) + }) + } + private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { let controllerBox = SpendDashboardControllerBox() let captureStore = SpendDashboardCapturedInputStore() diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 88e594eca..23902d4d7 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -124,9 +124,11 @@ struct SpendDashboardTokenProvenanceTests { store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -148,9 +150,11 @@ struct SpendDashboardTokenProvenanceTests { return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() } await store.refreshTokenUsageNow(for: .bedrock, force: true) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -177,9 +181,11 @@ struct SpendDashboardTokenProvenanceTests { } await store.refreshTokenUsageNow(for: .bedrock, force: true) let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 9e90f2b0c..df58513d2 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -101,6 +101,88 @@ struct StatusItemControllerMenuTests { #expect(percent == 25) } + @Test + func `claude switcher ignores exhausted scoped weekly carve outs`() { + let session = RateWindow( + usedPercent: 77, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 61, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let sonnet = RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot( + primary: session, + secondary: weekly, + tertiary: sonnet, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ]) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .claude, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 39) + } + + @Test + func `claude switcher keeps account weekly even when scoped carve out remains`() { + let session = RateWindow( + usedPercent: 20, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot( + primary: session, + secondary: weekly, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 85, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ]) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .claude, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 60) + } + @Test func `switcher preserves provider quota when no weekly allowance exists`() { let monthly = RateWindow( diff --git a/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift b/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift index 6c5f7747e..6f3ab8abd 100644 --- a/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift @@ -32,20 +32,21 @@ extension SyncCoordinatorTests { metadata: #require(ProviderDefaults.metadata[.kimi]), enabled: true) + let now = Date() let store = self.makeRateWindowIdentityUsageStore(settings: settings) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 24, - windowMinutes: nil, - resetsAt: Date(timeIntervalSince1970: 1_700_604_800), + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), resetDescription: "24/100 requests"), secondary: RateWindow( usedPercent: 75, windowMinutes: 300, - resetsAt: Date(timeIntervalSince1970: 1_700_018_000), + resetsAt: now.addingTimeInterval(5 * 60 * 60), resetDescription: "Rate: 15/20 per 5 hours"), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), + updatedAt: now), provider: .kimi) let mock = MockSyncPusher() @@ -60,6 +61,8 @@ extension SyncCoordinatorTests { let rateLimit = try #require(provider.rateWindows.first { $0.label == "Rate Limit" }) #expect(weekly.identity == .weekly) + #expect(weekly.windowMinutes == 10080) + #expect(weekly.pace != nil) #expect(rateLimit.identity == .session) } diff --git a/TestsLinux/AlibabaTokenPlanLinuxTests.swift b/TestsLinux/AlibabaTokenPlanLinuxTests.swift new file mode 100644 index 000000000..b39915de4 --- /dev/null +++ b/TestsLinux/AlibabaTokenPlanLinuxTests.swift @@ -0,0 +1,30 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct AlibabaTokenPlanLinuxTests { + @Test + func `manual cookie source does not require macOS web support`() { + // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies, so a manually + // configured cookie header must be usable off macOS (matches qoder/commandcode). + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=t")))) + } + + @Test + func `auto cookie source still requires web support off macOS`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } +} +#endif diff --git a/version.env b/version.env index db3df44ca..3e600f3d4 100644 --- a/version.env +++ b/version.env @@ -5,9 +5,9 @@ MOBILE_VERSION=1.11.3 # until the corresponding release reaches end users (Sparkle / App Store). # Bump this after the merged version is actually live, not at merge time. UPSTREAM_VERSION=v0.43.0 -UPSTREAM_SYNC_DATE=2026-07-21 +UPSTREAM_SYNC_DATE=2026-07-28 # Last upstream commit already merged/reviewed for the daily monitor. # 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=cc8da27cec92029a6435bfee4a703a719290234e +UPSTREAM_MONITOR_BASE=02b4ba278c81e667d2e5587d0ceb9eaf1d83f854