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
7 changes: 7 additions & 0 deletions Sources/CodexBar/Config/CodexBarConfigMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: this one-shot migration names the retired per-provider secret stores.
self.migrateTokenProviders(
[
(.zai, stores.zaiTokenStore.loadToken),
Expand Down Expand Up @@ -133,6 +134,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: these are the historical UserDefaults keys shipped before unified config.
let sources: [(UsageProvider, String)] = [
(.codex, "codexCookieSource"),
(.claude, "claudeCookieSource"),
Expand All @@ -157,6 +159,7 @@ struct CodexBarConfigMigrator {
}

if userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool == false {
// Provider-specific by design: the retired OpenAI web toggle controlled Codex dashboard cookies.
self.updateProvider(.codex, config: &config, state: &state) { entry in
guard entry.cookieSource == nil else { return false }
entry.cookieSource = .off
Expand All @@ -169,6 +172,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: old Moonshot API keys stored region separately from the unified config.
self.updateProvider(.moonshot, config: &config, state: &state) { entry in
guard entry.sanitizedAPIKey != nil, entry.sanitizedAPIKeyRegion == nil else { return false }
entry.apiKeyRegion = entry.sanitizedRegion ?? MoonshotRegion.international.rawValue
Expand Down Expand Up @@ -214,6 +218,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: MiniMax formerly split API token, region, and cookie across legacy stores.
let token = try? stores.minimaxAPITokenStore.loadToken()
let header = try? stores.minimaxCookieStore.loadCookieHeader()
if token != nil || header != nil {
Expand All @@ -238,6 +243,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: Kimi's legacy cookie could live in Keychain or kimiManualCookieHeader.
var token = try? stores.kimiTokenStore.loadToken()
if token?.isEmpty ?? true {
token = userDefaults.string(forKey: "kimiManualCookieHeader")
Expand All @@ -256,6 +262,7 @@ struct CodexBarConfigMigrator {
config: inout CodexBarConfig,
state: inout MigrationState)
{
// Provider-specific by design: OpenCode's retired store paired its cookie with opencodeWorkspaceID.
let header = try? stores.opencodeCookieStore.loadCookieHeader()
if header != nil {
state.sawLegacySecrets = true
Expand Down
18 changes: 10 additions & 8 deletions Sources/CodexBar/PreferencesProviderDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,20 @@ struct ProviderDetailView<SupplementaryContent: View>: View {
else {
return nil
}
guard provider == .openrouter || provider == .mimo || provider == .moonshot || provider == .poe else {
return (label: L("Plan"), value: rawPlan)
let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation.planRow
guard presentation.stripsBalancePrefix else {
return (label: L(presentation.label), value: rawPlan)
}

let prefix = "Balance:"
if rawPlan.hasPrefix(prefix) {
let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count)
let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedValue.isEmpty {
return (label: L("Balance"), value: trimmedValue)
return (label: L(presentation.balancePrefixedLabel), value: trimmedValue)
}
}
if provider == .mimo {
return (label: L("Plan"), value: rawPlan)
}
return (label: L("Balance"), value: rawPlan)
return (label: L(presentation.label), value: rawPlan)
}

private var menuBarSettingsPickers: [ProviderSettingsPickerDescriptor] {
Expand Down Expand Up @@ -303,6 +301,7 @@ private struct ProviderDetailInfoRows: View {
ProviderDetailInfoRow(label: L("Account"), value: self.model.email)
}

// Provider-specific by design: Kiro reports an auth method as a separate identity field, not a plan.
if self.provider == .kiro,
let authMethod = self.store.snapshot(for: self.provider.instanceID)?.loginMethod(for: .kiro),
!authMethod.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Expand Down Expand Up @@ -431,7 +430,10 @@ struct ProviderMetricsInlineView: View {
title: L("Cost"),
value: tokenUsage.sessionLine)
ProviderMetricInlineTextRow(title: "", value: tokenUsage.monthLine)
if self.model.provider == .codex, let hint = tokenUsage.hintLine, !hint.isEmpty {
if ProviderDescriptorRegistry.descriptor(for: self.model.provider).tokenCost.showsHintInProviderDetails,
let hint = tokenUsage.hintLine,
!hint.isEmpty
{
ProviderMetricInlineTextRow(title: "", value: hint)
}
}
Expand Down
23 changes: 15 additions & 8 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct ProvidersPane: View {
@State private var isAuthenticatingLiveCodexAccount = false

init(
// Provider-specific by design: Codex is the historical settings selection when no provider is supplied.
provider: UsageProvider = .codex,
settings: SettingsStore,
store: UsageStore,
Expand Down Expand Up @@ -151,6 +152,7 @@ struct ProvidersPane: View {
private func triggerRefresh(for provider: UsageProvider) {
Task { @MainActor in
await ProviderSettingsRefreshInteraction.perform {
// Provider-specific by design: Codex account reconciliation must refresh managed profile state too.
if provider == .codex {
await self.store.refreshCodexAccountScopedState(allowDisabled: true)
} else {
Expand Down Expand Up @@ -222,6 +224,8 @@ struct ProvidersPane: View {
}

func codexAccountsSectionState(for provider: UsageProvider) -> CodexAccountsSectionState? {
// Provider-specific by design: managed Codex profiles own app-only account promotion and reauthentication
// state.
guard provider == .codex else { return nil }
let projection = self.settings.codexVisibleAccountProjection
let degradedNotice: CodexAccountsSectionNotice? = if projection.hasUnreadableAddedAccountStore {
Expand Down Expand Up @@ -390,6 +394,7 @@ struct ProvidersPane: View {
func tokenAccountDescriptor(for provider: UsageProvider) -> ProviderSettingsTokenAccountsDescriptor? {
guard let support = TokenAccountSupportCatalog.support(for: provider) else { return nil }
let context = self.makeSettingsContext(provider: provider)
let implementation = ProviderCatalog.implementation(for: provider)
return ProviderSettingsTokenAccountsDescriptor(
id: "token-accounts-\(provider.rawValue)",
title: support.title,
Expand All @@ -415,8 +420,8 @@ struct ProvidersPane: View {
}
}
},
showsOrganizationField: provider == .claude,
showsTeamModeControls: provider == .zai,
showsOrganizationField: support.showsOrganizationField,
showsTeamModeControls: support.showsTeamModeControls,
addAccount: { label, token, usageScope, organizationID, workspaceID in
self.settings.addTokenAccount(
provider: provider,
Expand Down Expand Up @@ -452,13 +457,13 @@ struct ProvidersPane: View {
}
}
},
primaryAddActionTitle: provider == .copilot ? "Add Account" : nil,
primaryAddAction: provider == .copilot ? {
await CopilotLoginFlow.run(settings: self.settings)
primaryAddActionTitle: support.primaryAddActionTitle,
primaryAddAction: support.primaryAddActionTitle.map { _ in {
await implementation?.runTokenAccountPrimaryAction(context: context)
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshProvider(provider, allowDisabled: true)
}
} : nil,
} },
openConfigFile: {
self.settings.openTokenAccountsFile()
},
Expand Down Expand Up @@ -552,8 +557,9 @@ struct ProvidersPane: View {
tokenError = nil
}

// Abacus and Kimi carry their long-cadence window in primary rather than secondary.
let paceWindow = provider == .abacus || provider == .kimi ? snapshot?.primary : snapshot?.secondary
let paceWindow = snapshot.flatMap {
ProviderDescriptorRegistry.descriptor(for: provider).presentation.semanticWindows(snapshot: $0).weekly
}
let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
Expand Down Expand Up @@ -602,6 +608,7 @@ struct ProvidersPane: View {
}

func openAIWebDiagnostic(for provider: UsageProvider) -> String? {
// Provider-specific by design: the OpenAI dashboard diagnostic comes from Codex's app-only web session.
guard provider == .codex else { return nil }
let diagnostic = self.store.codexConsumerProjectionIfNeeded(
for: provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,9 @@ struct CopilotProviderImplementation: ProviderImplementation {
await CopilotLoginFlow.run(settings: context.controller.settings)
return true
}

@MainActor
func runTokenAccountPrimaryAction(context: ProviderSettingsContext) async {
await CopilotLoginFlow.run(settings: context.settings)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ protocol ProviderImplementation: Sendable {
@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution?

/// Optional primary action for the shared token-account editor.
@MainActor
func runTokenAccountPrimaryAction(context: ProviderSettingsContext) async

/// Optional hook to update provider settings when token accounts change.
@MainActor
func applyTokenAccountCookieSource(settings: SettingsStore)
Expand Down Expand Up @@ -163,6 +167,9 @@ extension ProviderImplementation {
ProviderDescriptorRegistry.descriptor(for: self.id).settingsSection.defaultContribution
}

@MainActor
func runTokenAccountPrimaryAction(context _: ProviderSettingsContext) async {}

@MainActor
func applyTokenAccountCookieSource(settings _: SettingsStore) {}

Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/SessionQuotaNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ enum SessionQuotaTransitionReducer {
state: self.baselineState(observation: observation))
}

// Provider-specific by design: Codex restore detection is owner- and reset-boundary-scoped to reject stale
// account observations after a switch.
let ownerChanged = observation.provider == .codex && previous.codexOwnerKey != observation.codexOwnerKey
guard previous.source == observation.source, !ownerChanged else {
return SessionQuotaTransitionEvaluation(
Expand Down Expand Up @@ -407,6 +409,8 @@ extension UsageStore {
provider: UsageProvider,
snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)?
{
// Provider-specific by design: MiMo/Qoder balances, Crof PAYG, Antigravity families, and Copilot chat
// fallback encode distinct session-quota payload semantics.
// MiMo/Qoder balances are never session quotas. Crof is handled below so quota-backed
// Crof snapshots can still participate when a real request-quota window is present.
guard provider != .mimo, provider != .qoder else { return nil }
Expand Down
22 changes: 17 additions & 5 deletions Sources/CodexBar/SettingsStore+ProviderDetection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ enum ProviderDetectionPolicy {
}

static func enabledProviders(signals: Signals) -> Set<UsageProvider> {
// Provider-specific by design: first-run detection probes these four concrete CLI/app credential sources.
var enabled: Set<UsageProvider> = []
if signals.codexCLIInstalled { enabled.insert(.codex) }
if signals.claudeCLIInstalled || signals.claudeDesktopInstalled { enabled.insert(.claude) }
if signals.geminiCLIInstalled, signals.geminiConfigured { enabled.insert(.gemini) }
if signals.antigravityAvailable { enabled.insert(.antigravity) }
if signals.codexCLIInstalled {
enabled.insert(.codex)
}
if signals.claudeCLIInstalled || signals.claudeDesktopInstalled {
enabled.insert(.claude)
}
if signals.geminiCLIInstalled, signals.geminiConfigured {
enabled.insert(.gemini)
}
if signals.antigravityAvailable {
enabled.insert(.antigravity)
}

// Keep the historical Codex default when no usable provider source is found.
if enabled.isEmpty { enabled.insert(.codex) }
if enabled.isEmpty {
enabled.insert(.codex)
}
return enabled
}
}
Expand All @@ -37,6 +48,7 @@ extension SettingsStore {

func applyProviderDetection() async {
guard !self.providerDetectionCompleted else { return }
// Provider-specific by design: detection reads each provider's installed app, CLI, or credential artifact.
let codexCLIInstalled = BinaryLocator.resolveCodexBinary() != nil
let claudeCLIInstalled = BinaryLocator.resolveClaudeBinary() != nil
let claudeDesktopInstalled = NSWorkspace.shared.urlForApplication(
Expand Down
20 changes: 14 additions & 6 deletions Sources/CodexBar/SettingsStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ extension SettingsStore {
/// Cursor keeps saved manual credentials when browser login switches back to Automatic, but those credentials
/// stay passive until the user explicitly selects one again.
func effectiveSelectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? {
if provider == .cursor, self.cursorCookieSource == .auto {
let support = TokenAccountSupportCatalog.support(for: provider)
if support?.selectedAccountRequiresManualCookieSource == true,
(self.providerConfig(for: provider)?.cookieSource ?? .auto) == .auto
{
return nil
}
return self.selectedTokenAccount(for: provider)
Expand Down Expand Up @@ -87,7 +90,7 @@ extension SettingsStore {
activeIndex: accounts.count)
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
if provider == .copilot {
if TokenAccountSupportCatalog.support(for: provider)?.clearsAPIKeyOnMutation == true {
entry.apiKey = nil
}
}
Expand Down Expand Up @@ -115,7 +118,9 @@ extension SettingsStore {

let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedToken, trimmedToken.isEmpty { return }
if let trimmedToken, trimmedToken.isEmpty {
return
}

let existing = data.accounts[index]
let resolvedIdentifier: String?
Expand Down Expand Up @@ -165,7 +170,7 @@ extension SettingsStore {
activeIndex: data.clampedActiveIndex())
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
if provider == .copilot {
if TokenAccountSupportCatalog.support(for: provider)?.clearsAPIKeyOnMutation == true {
entry.apiKey = nil
}
}
Expand Down Expand Up @@ -200,7 +205,7 @@ extension SettingsStore {
accounts: filtered,
activeIndex: nextActiveIndex)
}
if provider == .copilot {
if TokenAccountSupportCatalog.support(for: provider)?.clearsAPIKeyOnMutation == true {
entry.apiKey = nil
}
}
Expand All @@ -217,7 +222,9 @@ extension SettingsStore {
}

func ensureTokenAccountsLoaded() {
if self.tokenAccountsLoaded { return }
if self.tokenAccountsLoaded {
return
}
self.tokenAccountsLoaded = true
}

Expand Down Expand Up @@ -264,6 +271,7 @@ extension SettingsStore {
removedAccount: ProviderTokenAccount,
remainingAccounts: [ProviderTokenAccount])
{
// Provider-specific by design: removing the final Antigravity account must delete its shared OAuth cache.
guard provider == .antigravity else { return }
guard let removedCredentials = AntigravityOAuthCredentialsStore.credentials(
fromTokenAccountValue: removedAccount.token)
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/SettingsStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

extension SettingsStore {
func costSummaryShowsInlineDashboard(for provider: UsageProvider) -> Bool {
// DeepSeek has no cost submenu, so any enabled cost-summary style falls back to inline.
// Provider-specific by design: DeepSeek's API exposes a balance card but no token-cost submenu data.
if provider == .deepseek {
return self.costUsageEnabled
}
Expand Down Expand Up @@ -39,6 +39,7 @@ extension SettingsStore {
homeDirectory: URL? = nil,
workingDirectory: URL? = nil) -> Bool
{
// Provider-specific by design: only Codex and Claude have local JSONL scanners that can auto-enable token cost.
let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser

func hasAnyJsonl(in root: URL) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum ClaudeSwapMenuPrecedence {
accountCount: Int,
showSingleAccount: Bool) -> Bool
{
// Provider-specific by design: claude-swap subprocess discovery owns Claude account presentation.
provider == .claude && ClaudeSwapAccountProjection.shouldPresentAccounts(
accountCount: accountCount,
showSingleAccount: showSingleAccount)
Expand Down Expand Up @@ -97,6 +98,7 @@ extension StatusItemController {
}

func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? {
// Provider-specific by design: managed Codex profiles use reconciled visible-account projection state.
guard provider == .codex else { return nil }
guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil }
guard projection.visibleAccounts.count > 1 else { return nil }
Expand Down
Loading