Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@ on Columbus Labs QuotaKit releases and product-facing changes.

### Changed

- Synced reviewed CodexBar development through `c9e7f4df5`, adopting Claude cookie-refresh safety, OpenCodex pricing performance, Gemini consumer-shutdown detection, and merged Warp icon correctness while preserving QuotaKit identity, provider/mobile contracts, and release/build ownership.
- Synced reviewed CodexBar development through `100deb6fa`, preserving QuotaKit identity, release ownership, provider/mobile contracts, and build numbers; upstream credit-only changelog bookkeeping and an incompatible Grok period fallback were reviewed but not imported.
- Synced reviewed CodexBar development through `4b14ed9c5`, adopting eleven product/test commits while excluding upstream credit-only and release bookkeeping, and preserving QuotaKit identity, all 69 providers, Mac-to-iPhone contracts, and release/build ownership.
- Synced reviewed CodexBar development through `7c64d280f`, adopting provider, authentication, dashboard, widget, presentation, and Mac lifecycle work while preserving QuotaKit identity, Columbus Labs release ownership, redacted-by-default serving, the full provider registry, CloudKit/App Group and typed iPhone contracts, and all release/build numbers.
- Claude: preserve OAuth authority and actionable prompt-free web recovery errors, refresh the displayed CLI version after explicit usage fetches, and retain QuotaKit's stricter rule that opaque Claude CLI processes run only from explicit user actions.

### Fixed

- Claude: prefer Chrome without dropping fallback browsers, stop after the first usable cookie source, and keep ad-hoc browser-cookie caches process-local without moving OAuth credentials out of Keychain.
- Gemini: detect Google's live consumer-tier shutdown response, keep Workspace and licensed accounts eligible, and warn before an explicit account-switch flow clears credentials.
- Usage & Spend: price each OpenCodex entry once with shared catalog and custom-pricing context while preserving overflow-safe aggregation and compatible local caches.
- Menu bar: preserve Warp's bonus lane in merged icons when unused while continuing to distinguish a genuinely exhausted bonus.
- Providers: restore Qwen Cloud Brave cookie import, use the latest completed UTC day for OpenRouter activity, and improve Antigravity offline discovery without attributing machine-global data to an OAuth account or iCloud record.
- Usage & Spend: read compatible local Cursor and Antigravity activity caches, preserve refresh cancellation, refresh all changed dashboard detail, and keep calendar bucketing consistent across heatmap labels and selection.
- Mac: report non-writable CLI path conflicts and let a single meaningful quota fill the menu icon while retaining Claude's explicit missing-secondary lane.
Expand Down
13 changes: 11 additions & 2 deletions Sources/CodexBar/GeminiLoginRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ enum GeminiLoginRunner {
case success
case missingBinary
case launchFailed(String)
/// Google's consumer-tier shutdown was observed: launching Gemini CLI would only fail its
/// OAuth step, so keep the stored credentials and steer the user to Antigravity instead.
case consumerTierDeprecated
}

let outcome: Outcome
}

static func run(onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result {
await Task(priority: .userInitiated) {
static func run(
consumerTierDeprecationObserved: Bool = false,
onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result
{
guard !consumerTierDeprecationObserved else {
return Result(outcome: .consumerTierDeprecated)
}
return await Task(priority: .userInitiated) {
let env = ProcessInfo.processInfo.environment
guard let binary = BinaryLocator.resolveGeminiBinary(
env: env,
Expand Down
11 changes: 6 additions & 5 deletions Sources/CodexBar/IconRemainingResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import CodexBarCore
import Foundation

enum IconRemainingResolver {
private static let visibleZeroPercent = 0.0001
/// The renderer caches percentages in tenths. This is the smallest robust value that has a distinct cache
/// key from zero while still rounding to a zero-pixel fill in the 30-pixel meter.
private static let visibleZeroPercent = 0.1

static func resolvedWindows(
snapshot: UsageSnapshot,
Expand Down Expand Up @@ -43,7 +45,6 @@ enum IconRemainingResolver {
snapshot: UsageSnapshot,
style: IconStyle,
showUsed: Bool,
renderingStyle: IconStyle? = nil,
secondaryOverrideWindowID: String? = nil,
now: Date = Date())
-> (primary: Double?, secondary: Double?)
Expand All @@ -56,13 +57,13 @@ enum IconRemainingResolver {
var percents = (
primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent,
secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent)
// Provider style chooses the usage lanes; rendering style controls renderer-specific layout sentinels.
// Merged icons still resolve Warp's lanes, but render as `.combined` and must keep the real percentage.
// Provider style chooses both the usage lanes and provider-specific layout sentinels. This must also
// apply when the visual rendering style is `.combined`, because the renderer receives provider policy
// separately from its visual style.
let presentation = UsageProvider(rawValue: style.rawValue)
.map { ProviderDescriptorRegistry.descriptor(for: $0).presentation }
if showUsed,
presentation?.treatsExhaustedSecondaryIconWindowAsMissing == true,
(renderingStyle ?? style) == style,
let secondary = windows.secondary
{
if secondary.remainingPercent <= 0 {
Expand Down
17 changes: 15 additions & 2 deletions Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,28 @@ import CodexBarCore
extension StatusItemController {
func runGeminiLoginFlow() async {
let store = self.store
let result = await GeminiLoginRunner.run {
let onCredentialsCreated: @Sendable () -> Void = {
Task { @MainActor in
await store.refresh()
CodexBarLog.logger(LogCategories.login).info("Auto-refreshed after Gemini auth")
}
}

var result = await GeminiLoginRunner.run(
consumerTierDeprecationObserved: store.geminiObservedGoogleConsumerTierShutdown,
onCredentialsCreated: onCredentialsCreated)
guard !Task.isCancelled else { return }
self.loginPhase = .idle
self.presentGeminiLoginResult(result)

if self.presentGeminiLoginResult(result) {
// The alert warned that continuing clears the stored credentials; the user asked to switch to
// an account Google still serves, so run the ordinary flow without the shutdown guard.
self.loginLogger.info("Gemini login", metadata: ["outcome": "consumerTierDeprecatedOverride"])
result = await GeminiLoginRunner.run(onCredentialsCreated: onCredentialsCreated)
guard !Task.isCancelled else { return }
self.presentGeminiLoginResult(result)
}

let outcome = self.describe(result.outcome)
self.loginLogger.info("Gemini login", metadata: ["outcome": outcome])
}
Expand Down
36 changes: 33 additions & 3 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
case .success: "success"
case .missingBinary: "missingBinary"
case let .launchFailed(message): "launchFailed(\(message))"
case .consumerTierDeprecated: "consumerTierDeprecated"
}
}

Expand All @@ -813,9 +814,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
}
}

func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) {
guard let info = Self.geminiLoginAlertInfo(for: result) else { return }
self.presentLoginAlert(title: info.title, message: info.message)
/// Returns `true` when the alert offered a recovery action and the user chose it.
@discardableResult
func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) -> Bool {
guard let info = Self.geminiLoginAlertInfo(for: result) else { return false }
guard let confirmButtonTitle = info.confirmButtonTitle else {
self.presentLoginAlert(title: info.title, message: info.message)
return false
}
return self.presentLoginConfirmation(
title: info.title,
message: info.message,
confirmButtonTitle: confirmButtonTitle)
}

func presentAntigravityLoginResult(_ result: AntigravityLoginRunner.Result) {
Expand All @@ -826,6 +836,8 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
struct LoginAlertInfo: Equatable {
let title: String
let message: String
/// When set, the alert offers this action alongside Cancel and reports whether it was chosen.
var confirmButtonTitle: String?
}

nonisolated static func geminiLoginAlertInfo(for result: GeminiLoginRunner.Result) -> LoginAlertInfo? {
Expand All @@ -838,6 +850,12 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
message: L("Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."))
case let .launchFailed(message):
LoginAlertInfo(title: L("Could not open Terminal for Gemini"), message: message)
case .consumerTierDeprecated:
LoginAlertInfo(
title: L("Gemini CLI login is no longer supported"),
message: GeminiConsumerTierMigration.deprecationError + "\n\n"
+ GeminiConsumerTierMigration.loginSwitchAccountPrompt,
confirmButtonTitle: L("Switch Account…"))
}
}

Expand All @@ -858,6 +876,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
}
}

/// Cancel is the default button on purpose: confirming clears stored provider credentials, so a
/// stray Return keypress must not destroy them.
func presentLoginConfirmation(title: String, message: String, confirmButtonTitle: String) -> Bool {
let alert = NSAlert()
alert.messageText = L(title)
alert.informativeText = L(message)
alert.alertStyle = .warning
alert.addButton(withTitle: L("Cancel"))
alert.addButton(withTitle: confirmButtonTitle)
return alert.runModal() == .alertSecondButtonReturn
}

func presentLoginAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = L(title)
Expand Down
7 changes: 2 additions & 5 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,7 @@ extension StatusItemController {
provider: primaryProvider,
snapshot: snapshot,
style: resolverStyle,
showUsed: showUsed,
renderingStyle: style)
showUsed: showUsed)
var primary = resolved?.primary
var weekly = resolved?.secondary
var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot)
Expand Down Expand Up @@ -739,8 +738,7 @@ extension StatusItemController {
provider: UsageProvider,
snapshot: UsageSnapshot?,
style: IconStyle,
showUsed: Bool,
renderingStyle: IconStyle? = nil)
showUsed: Bool)
-> (primary: Double?, secondary: Double?)?
{
guard let snapshot else { return nil }
Expand All @@ -765,7 +763,6 @@ extension StatusItemController {
snapshot: snapshot,
style: style,
showUsed: showUsed,
renderingStyle: renderingStyle,
secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: snapshot))
}

Expand Down
38 changes: 35 additions & 3 deletions Sources/CodexBar/UsageStore+GeminiMigration.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import CodexBarCore

/// Which Gemini migration sentinel the last refresh produced.
enum GeminiMigrationObservation {
case none
/// QuotaKit could not read OAuth client credentials from the local Gemini CLI while Antigravity is
/// installed. A local tooling problem: reinstalling or relaunching Gemini CLI is still the fix.
case localAntigravityHandoff
/// Google itself answered with the consumer-tier shutdown. Gemini CLI sign-in cannot succeed.
case googleConsumerTierShutdown
}

extension UsageStore {
/// Either sentinel: drives the "Enable Antigravity provider" settings action.
var geminiObservedConsumerTierDeprecation: Bool {
self.geminiMigrationObservation != .none
}

/// Only Google's own shutdown response. Narrower than `geminiObservedConsumerTierDeprecation` so the
/// login guard cannot block a Workspace user whose local Gemini CLI install merely failed to yield
/// OAuth client credentials.
var geminiObservedGoogleConsumerTierShutdown: Bool {
self.geminiMigrationObservation == .googleConsumerTierShutdown
}

static func isGeminiConsumerTierDeprecationError(_ error: Error?) -> Bool {
switch error as? GeminiStatusProbeError {
case .consumerTierDeprecated, .oauthCredentialsUnavailableWithAntigravity:
Expand All @@ -11,11 +33,21 @@ extension UsageStore {
}

func observeGeminiConsumerTierDeprecation(from error: Error) {
guard Self.isGeminiConsumerTierDeprecationError(error) else { return }
self.geminiObservedConsumerTierDeprecation = true
switch error as? GeminiStatusProbeError {
case .consumerTierDeprecated:
self.geminiMigrationObservation = .googleConsumerTierShutdown
case .oauthCredentialsUnavailableWithAntigravity:
// Never downgrade a shutdown already seen this session: Google's response is the stronger
// signal, and a later local-tooling failure must not re-arm the destructive login path.
if self.geminiMigrationObservation != .googleConsumerTierShutdown {
self.geminiMigrationObservation = .localAntigravityHandoff
}
default:
return
}
}

func clearGeminiConsumerTierDeprecationObservation() {
self.geminiObservedConsumerTierDeprecation = false
self.geminiMigrationObservation = .none
}
}
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ final class UsageStore {
var snapshots: [ProviderInstanceID: UsageSnapshot] = [:]
var errors: [ProviderInstanceID: String] = [:]
var diagnostics: [ProviderInstanceID: String] = [:]
var geminiObservedConsumerTierDeprecation = false
var geminiMigrationObservation: GeminiMigrationObservation = .none
var knownLimitsAvailabilityByProvider: [ProviderInstanceID: UsageLimitsAvailability] = [:]
var lastSourceLabels: [ProviderInstanceID: String] = [:]
var lastFetchAttempts: [ProviderInstanceID: [ProviderFetchAttempt]] = [:]
Expand Down
19 changes: 19 additions & 0 deletions Sources/CodexBarCore/BrowserCookieAccessGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public enum BrowserCookieAccessGate {
private static let log = CodexBarLog.logger(LogCategories.browserCookieGate)
@TaskLocal private static var explicitRetryScope: ExplicitRetryScope?
@TaskLocal private static var deniedBrowsersForTesting: [Browser]?
#if DEBUG
@TaskLocal private static var shouldAttemptOverrideForTesting: Bool?
#endif

static let allowTestCookieAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_BROWSER_COOKIE_ACCESS"

Expand All @@ -84,6 +87,11 @@ public enum BrowserCookieAccessGate {
}

public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool {
#if DEBUG
if let shouldAttemptOverrideForTesting {
return shouldAttemptOverrideForTesting
}
#endif
guard browser.usesKeychainForCookieDecryption else { return true }
guard !KeychainAccessGate.isDisabled else { return false }
guard ProviderInteractionContext.current == .userInitiated else {
Expand Down Expand Up @@ -167,6 +175,17 @@ public enum BrowserCookieAccessGate {
}
}

#if DEBUG
static func withShouldAttemptOverrideForTesting<T>(
_ result: Bool?,
operation: () throws -> T) rethrows -> T
{
try self.$shouldAttemptOverrideForTesting.withValue(result) {
try operation()
}
}
#endif

static func operationPreservingAccessContext<T: Sendable>(
_ operation: @escaping @Sendable () throws -> T) -> @Sendable () throws -> T
{
Expand Down
10 changes: 7 additions & 3 deletions Sources/CodexBarCore/BrowserCookieImportOrder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ extension [Browser] {
///
/// This is intentionally stricter than "app installed": it aims to avoid unnecessary Keychain prompts.
public func cookieImportCandidates(using detection: BrowserDetection) -> [Browser] {
let candidates = self.filter { browser in
Array(self.lazyCookieImportCandidates(using: detection))
}

/// Lazily filters browser sources so callers can stop after the first successful cookie import.
func lazyCookieImportCandidates(using detection: BrowserDetection) -> some Sequence<Browser> {
self.lazy.filter { browser in
if KeychainAccessGate.isDisabled, browser.usesKeychainForCookieDecryption {
return false
}
return detection.isCookieSourceAvailable(browser)
return detection.isCookieSourceAvailable(browser) && BrowserCookieAccessGate.shouldAttempt(browser)
}
return candidates.filter { BrowserCookieAccessGate.shouldAttempt($0) }
}

/// Filters a browser list to sources with usable profile data on disk.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "be0bb04e9e92b697"
static let value = "f22371c47d2e006f"
}
26 changes: 26 additions & 0 deletions Sources/CodexBarCore/KeychainAccessGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ public enum KeychainAccessGate {
private static let flagKey = "debugDisableKeychainAccess"
static let disableAccessEnvironmentKey = "CODEXBAR_DISABLE_KEYCHAIN_ACCESS"
@TaskLocal private static var taskOverrideValue: Bool?
#if DEBUG
@TaskLocal private static var storedOverrideForTesting: Bool?
#endif
// All mutable gate state and mirror writes share this lock. Resolve the effective value with
// `isDisabledLocked()` instead of recursively entering through the public getter.
private static let stateLock = NSLock()
Expand Down Expand Up @@ -38,6 +41,9 @@ public enum KeychainAccessGate {
if Self.forcesDisabledUnderTests { return true }
#endif
if self.processForceDisabledReason != nil { return true }
#if DEBUG
if let storedOverrideForTesting { return storedOverrideForTesting }
#endif
if let overrideValue { return overrideValue }
if UserDefaults.standard.bool(forKey: Self.flagKey) { return true }
if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true }
Expand All @@ -55,6 +61,9 @@ public enum KeychainAccessGate {
if let taskOverrideValue { return taskOverrideValue }
if self.isDisabledByEnvironment() { return true }
if self.processForceDisabledReason != nil { return true }
#if DEBUG
if let storedOverrideForTesting { return storedOverrideForTesting }
#endif
if let overrideValue { return overrideValue }
if UserDefaults.standard.bool(forKey: Self.flagKey) { return true }
if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true }
Expand Down Expand Up @@ -115,8 +124,25 @@ public enum KeychainAccessGate {
}
}

#if DEBUG
static func withStoredOverrideForTesting<T>(
_ disabled: Bool?,
operation: () throws -> T) rethrows -> T
{
try self.$storedOverrideForTesting.withValue(disabled) {
try operation()
}
}
#endif

static var currentOverrideForTesting: Bool? {
#if DEBUG
self.taskOverrideValue
?? self.storedOverrideForTesting
?? self.stateLock.withLock { self.overrideValue }
#else
self.taskOverrideValue ?? self.stateLock.withLock { self.overrideValue }
#endif
}

#if DEBUG
Expand Down
Loading