Files
steipete--codexbar/Sources/CodexBar/SettingsStore.swift
T
2026-07-13 12:22:33 +08:00

869 lines
40 KiB
Swift

import AppKit
import CodexBarCore
import Observation
import ServiceManagement
enum RefreshFrequency: String, CaseIterable, Identifiable {
case manual
case oneMinute
case twoMinutes
case fiveMinutes
case fifteenMinutes
case thirtyMinutes
/// Newest/most advanced option; kept last so the picker still lists the fixed intervals
/// in ascending cadence order before it.
case adaptive
var id: String {
self.rawValue
}
/// nil for `.manual` (no timer) and `.adaptive` (delay is computed per tick by
/// `AdaptiveRefreshPolicy`, not a fixed interval).
var seconds: TimeInterval? {
switch self {
case .manual: nil
case .oneMinute: 60
case .twoMinutes: 120
case .fiveMinutes: 300
case .fifteenMinutes: 900
case .thirtyMinutes: 1800
case .adaptive: nil
}
}
var label: String {
switch self {
case .manual: L("refresh_manual")
case .oneMinute: L("refresh_1min")
case .twoMinutes: L("refresh_2min")
case .fiveMinutes: L("refresh_5min")
case .fifteenMinutes: L("refresh_15min")
case .thirtyMinutes: L("refresh_30min")
case .adaptive: L("refresh_adaptive")
}
}
}
enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
case automatic
case primary
case secondary
case primaryAndSecondary
case tertiary
case extraUsage
case average
case monthlyPlan
var id: String {
self.rawValue
}
var label: String {
switch self {
case .automatic: L("metric_pref_automatic")
case .primary: L("metric_pref_primary")
case .secondary: L("metric_pref_secondary")
case .primaryAndSecondary: "\(L("metric_pref_primary")) + \(L("metric_pref_secondary"))"
case .tertiary: L("metric_pref_tertiary")
case .extraUsage: L("metric_pref_extra_usage")
case .average: L("metric_pref_average")
case .monthlyPlan: L("metric_mistral_monthly_plan")
}
}
}
enum KiroMenuBarDisplayMode: String, CaseIterable, Identifiable {
case automatic
case hidden
case creditsLeft
case percentLeft
case creditsAndPercent
case usedAndTotal
case overageCreditsWhenExhausted
case overageCostWhenExhausted
case overageCreditsAndCostWhenExhausted
var id: String {
self.rawValue
}
var label: String {
switch self {
case .automatic: L("Automatic")
case .hidden: L("Hidden")
case .creditsLeft: L("Credits left")
case .percentLeft: L("Percent left")
case .creditsAndPercent: L("Credits + percent")
case .usedAndTotal: L("Used / total")
case .overageCreditsWhenExhausted: L("Overage credits at zero")
case .overageCostWhenExhausted: L("Overage cost at zero")
case .overageCreditsAndCostWhenExhausted: L("Overage credits + cost at zero")
}
}
}
enum MultiAccountMenuLayout: String, CaseIterable, Identifiable {
case segmented
case stacked
var id: String {
self.rawValue
}
var label: String {
switch self {
case .segmented: L("multi_account_layout_segmented")
case .stacked: L("multi_account_layout_stacked")
}
}
}
enum CostSummaryDisplayStyle: String, CaseIterable, Identifiable {
case inlineSummary
case costSubmenu
case both
var id: String {
self.rawValue
}
var label: String {
switch self {
case .inlineSummary: L("cost_summary_style_inline")
case .costSubmenu: L("cost_summary_style_submenu")
case .both: L("cost_summary_style_both")
}
}
var helpText: String {
switch self {
case .inlineSummary: L("cost_summary_style_inline_help")
case .costSubmenu: L("cost_summary_style_submenu_help")
case .both: L("cost_summary_style_both_help")
}
}
var showsInlineSummary: Bool {
self != .costSubmenu
}
var showsCostSubmenu: Bool {
self != .inlineSummary
}
}
struct CachedCodexAccountReconciliationSnapshot {
let activeSource: CodexActiveSource
let loadedAt: Date
let snapshot: CodexAccountReconciliationSnapshot
}
struct CachedCodexAccountMenuProjection: Equatable {
let activeSource: CodexActiveSource
let loadedAt: Date
let projection: CodexVisibleAccountProjection
}
enum CodexAccountMenuProjectionRevalidationResult: Equatable {
case skipped
case discarded
case unchanged
case updated
}
@MainActor
@Observable
final class SettingsStore {
static let sharedDefaults = AppGroupSupport.sharedDefaults()
static let mergedOverviewProviderLimit = 3
static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2
static let isRunningTests: Bool = {
let env = ProcessInfo.processInfo.environment
if env["XCTestConfigurationFilePath"] != nil {
return true
}
if env["TESTING_LIBRARY_VERSION"] != nil {
return true
}
if env["SWIFT_TESTING"] != nil {
return true
}
return NSClassFromString("XCTestCase") != nil
}()
#if DEBUG
static var codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting: TimeInterval?
#endif
@ObservationIgnored let userDefaults: UserDefaults
@ObservationIgnored let configStore: CodexBarConfigStore
@ObservationIgnored let antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore
@ObservationIgnored var config: CodexBarConfig
@ObservationIgnored var configPersistTask: Task<Void, Never>?
@ObservationIgnored var configLoading = false
@ObservationIgnored var tokenAccountsLoaded = false
@ObservationIgnored var cachedCodexAccountReconciliationSnapshot:
CachedCodexAccountReconciliationSnapshot?
@ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection?
@ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0
#if DEBUG
@ObservationIgnored var _test_codexAccountSnapshotLoader:
(@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)?
#endif
@ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false
@ObservationIgnored var selectedMenuProviderRawStorage: String?
var defaultsState: SettingsDefaultsState
var configRevision: Int = 0
var backgroundWorkSettingsRevision: Int = 0
var providerOrder: [UsageProvider] = []
var providerEnablement: [UsageProvider: Bool] = [:]
@ObservationIgnored var providerEnablementRevisions: [UsageProvider: UInt64] = [:]
@ObservationIgnored var providerConfigRevisions: [UsageProvider: UInt64] = [:]
@ObservationIgnored var providerConfigFingerprints: [UsageProvider: Data] = [:]
static func shouldBridgeSharedDefaults(for userDefaults: UserDefaults) -> Bool {
if !self.isRunningTests {
return true
}
if userDefaults === UserDefaults.standard {
return true
}
if let shared = sharedDefaults, userDefaults === shared {
return true
}
return false
}
init(
userDefaults: UserDefaults = .standard,
configStore: CodexBarConfigStore = CodexBarConfigStore(),
zaiTokenStore: any ZaiTokenStoring = KeychainZaiTokenStore(),
syntheticTokenStore: any SyntheticTokenStoring = KeychainSyntheticTokenStore(),
codexCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "codex-cookie",
promptKind: .codexCookie),
claudeCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "claude-cookie",
promptKind: .claudeCookie),
cursorCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "cursor-cookie",
promptKind: .cursorCookie),
opencodeCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "opencode-cookie",
promptKind: .opencodeCookie),
factoryCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "factory-cookie",
promptKind: .factoryCookie),
minimaxCookieStore: any MiniMaxCookieStoring = KeychainMiniMaxCookieStore(),
minimaxAPITokenStore: any MiniMaxAPITokenStoring = KeychainMiniMaxAPITokenStore(),
kimiTokenStore: any KimiTokenStoring = KeychainKimiTokenStore(),
kimiK2TokenStore: any KimiK2TokenStoring = KeychainKimiK2TokenStore(),
augmentCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "augment-cookie",
promptKind: .augmentCookie),
ampCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore(
account: "amp-cookie",
promptKind: .ampCookie),
copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(),
tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore(),
antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore(),
performInitialProviderDetection: Bool = !SettingsStore.isRunningTests)
{
let appGroupID = AppGroupSupport.currentGroupID()
let appGroupMigration: AppGroupSupport.MigrationResult
if Self.isRunningTests {
appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults)
} else {
Self.scheduleAppGroupMigration()
appGroupMigration = AppGroupSupport.MigrationResult(status: .targetUnavailable)
}
let sharedDefaultsAvailable = Self.sharedDefaults != nil
if !Self.isRunningTests {
CodexBarLog.logger(LogCategories.settings).info(
"App group resolved",
metadata: [
"groupID": appGroupID,
"sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0",
"migrationStatus": appGroupMigration.status.rawValue,
"migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0",
"migratedDefaults": "\(appGroupMigration.copiedDefaults)",
])
}
if userDefaults.object(forKey: "openAIWebAccessEnabled") == nil,
let legacyOpenAIWebAccess = userDefaults.object(forKey: "openAIWebAccess") as? Bool
{
userDefaults.set(legacyOpenAIWebAccess, forKey: "openAIWebAccessEnabled")
}
let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil
let hadExistingConfig = (try? configStore.load()) != nil
let legacyStores = CodexBarConfigMigrator.LegacyStores(
zaiTokenStore: zaiTokenStore,
syntheticTokenStore: syntheticTokenStore,
codexCookieStore: codexCookieStore,
claudeCookieStore: claudeCookieStore,
cursorCookieStore: cursorCookieStore,
opencodeCookieStore: opencodeCookieStore,
factoryCookieStore: factoryCookieStore,
minimaxCookieStore: minimaxCookieStore,
minimaxAPITokenStore: minimaxAPITokenStore,
kimiTokenStore: kimiTokenStore,
kimiK2TokenStore: kimiK2TokenStore,
augmentCookieStore: augmentCookieStore,
ampCookieStore: ampCookieStore,
copilotTokenStore: copilotTokenStore,
tokenAccountStore: tokenAccountStore)
let config = CodexBarConfigMigrator.loadOrMigrate(
configStore: configStore,
userDefaults: userDefaults,
stores: legacyStores)
self.userDefaults = userDefaults
self.configStore = configStore
self.antigravityOAuthCredentialsStore = antigravityOAuthCredentialsStore
self.config = config
self.configLoading = true
let defaultsState = Self.loadDefaultsState(userDefaults: userDefaults)
self.defaultsState = defaultsState
self.mergedMenuLastSelectedWasOverviewStorage = defaultsState.mergedMenuLastSelectedWasOverview
self.selectedMenuProviderRawStorage = defaultsState.selectedMenuProviderRaw
self.updateProviderState(config: config)
self.configLoading = false
CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled)
userDefaults.removeObject(forKey: "showCodexUsage")
userDefaults.removeObject(forKey: "showClaudeUsage")
LaunchAtLoginManager.setEnabled(self.launchAtLogin)
if performInitialProviderDetection {
self.runInitialProviderDetectionIfNeeded()
}
self.ensureAlibabaProviderAutoEnabledIfNeeded()
self.applyTokenCostDefaultIfNeeded()
if self.claudeUsageDataSource != .cli {
if Self.isRunningTests {
self.claudeWebExtrasEnabled = false
} else {
self.defaultsState.claudeWebExtrasEnabledRaw = false
}
}
let resolvedOpenAIWebAccessEnabled = if hasStoredOpenAIWebAccessPreference {
self.defaultsState.openAIWebAccessEnabled
} else {
Self.inferredInitialOpenAIWebAccessEnabled(
config: config,
hadExistingConfig: hadExistingConfig)
}
if Self.isRunningTests {
self.openAIWebAccessEnabled = resolvedOpenAIWebAccessEnabled
} else {
self.defaultsState.openAIWebAccessEnabled = resolvedOpenAIWebAccessEnabled
}
KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess
}
}
extension SettingsStore {
private struct NotificationDefaults {
let statusChecksEnabled: Bool
let sessionQuotaNotificationsEnabled: Bool
let predictivePaceWarningNotificationsEnabled: Bool
}
private static func scheduleAppGroupMigration() {
Task.detached(priority: .utility) {
let result = AppGroupSupport.migrateLegacyDataIfNeeded()
CodexBarLog.logger(LogCategories.settings).info(
"App group migration completed",
metadata: [
"migrationStatus": result.status.rawValue,
"migratedSnapshot": result.copiedSnapshot ? "1" : "0",
"migratedDefaults": "\(result.copiedDefaults)",
])
}
}
private static func inferredInitialOpenAIWebAccessEnabled(
config: CodexBarConfig,
hadExistingConfig: Bool) -> Bool
{
guard let codex = config.providerConfig(for: .codex) else { return false }
if let cookieSource = codex.cookieSource {
return cookieSource.isEnabled
}
if codex.sanitizedCookieHeader != nil {
return true
}
return hadExistingConfig
}
// swiftlint:disable:next function_body_length
private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState {
let refreshDefault = userDefaults.string(forKey: "refreshFrequency")
.flatMap(RefreshFrequency.init(rawValue:))
let refreshFrequency = refreshDefault ?? .fiveMinutes
if Self.isRunningTests, refreshDefault == nil {
userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency")
}
let refreshAllProvidersOnMenuOpen = userDefaults.object(
forKey: "refreshAllProvidersOnMenuOpen") as? Bool ?? false
let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false
let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false
let debugDisableKeychainAccess = Self.loadDebugDisableKeychainAccess(userDefaults: userDefaults)
let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false
let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue
if Self.isRunningTests, userDefaults.string(forKey: "debugLogLevel") == nil {
userDefaults.set(debugLogLevelRaw, forKey: "debugLogLevel")
}
let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern")
let debugKeepCLISessionsAlive = userDefaults.object(forKey: "debugKeepCLISessionsAlive") as? Bool ?? false
let notificationDefaults = Self.loadNotificationDefaults(userDefaults: userDefaults)
let quotaWarnings = Self.loadQuotaWarningDefaults(userDefaults: userDefaults)
let quotaWarningMarkersVisibleDefault = userDefaults.object(forKey: "quotaWarningMarkersVisible") as? Bool
let quotaWarningMarkersVisible = quotaWarningMarkersVisibleDefault ?? true
if Self.isRunningTests, quotaWarningMarkersVisibleDefault == nil {
userDefaults.set(true, forKey: "quotaWarningMarkersVisible")
}
let weeklyProgressWorkDays = userDefaults.object(forKey: "weeklyProgressWorkDays") as? Int
let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false
let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false
let providerChangelogLinksEnabled = userDefaults.object(
forKey: "providerChangelogLinksEnabled") as? Bool ?? false
let menuBarShowsBrandIconWithPercent = userDefaults.object(
forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false
let menuBarHidesCritters = userDefaults.object(forKey: "menuBarHidesCritters") as? Bool ?? false
let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode")
?? MenuBarDisplayMode.percent.rawValue
let menuBarShowsResetTimeWhenExhausted = userDefaults.object(
forKey: "menuBarShowsResetTimeWhenExhausted") as? Bool ?? false
let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode")
?? KiroMenuBarDisplayMode.automatic.rawValue
let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false
let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults)
let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults)
let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false
let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults)
let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false
let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30
let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays))
let costComparisonPeriodsEnabled = userDefaults.object(
forKey: "costComparisonPeriodsEnabled") as? Bool ?? false
let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw(
userDefaults: userDefaults,
costUsageEnabled: costUsageEnabled)
let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false
let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false
let confettiOnReset = Self.loadConfettiOnResetDefaults(userDefaults: userDefaults)
let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false
let claudeOAuthKeychainReadStrategyRaw = Self.loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: userDefaults)
let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode")
let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false
let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool
let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true
if Self.isRunningTests, creditsExtrasDefault == nil {
userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage")
}
let codexSparkUsageVisibleDefault = userDefaults.object(forKey: "codexSparkUsageVisible") as? Bool
let codexSparkUsageVisible = codexSparkUsageVisibleDefault ?? true
if Self.isRunningTests, codexSparkUsageVisibleDefault == nil {
userDefaults.set(true, forKey: "codexSparkUsageVisible")
}
let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool
let openAIWebAccessEnabled = openAIWebAccessDefault ?? false
if Self.isRunningTests, openAIWebAccessDefault == nil {
userDefaults.set(false, forKey: "openAIWebAccessEnabled")
}
let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool
let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? false
if Self.isRunningTests, openAIWebBatterySaverDefault == nil {
userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled")
}
let providerStorageFootprintsDefault = userDefaults.object(forKey: "providerStorageFootprintsEnabled") as? Bool
let providerStorageFootprintsEnabled = providerStorageFootprintsDefault ?? false
if Self.isRunningTests, providerStorageFootprintsDefault == nil {
userDefaults.set(false, forKey: "providerStorageFootprintsEnabled")
}
let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? ""
let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true
let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true
let mergedMenuLastSelectedWasOverview = userDefaults.object(
forKey: "mergedMenuLastSelectedWasOverview") as? Bool ?? false
let mergedOverviewSelectedProvidersRaw = userDefaults.array(
forKey: "mergedOverviewSelectedProviders") as? [String] ?? []
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false
let providersSortedAlphabetically = userDefaults.object(
forKey: "providersSortedAlphabetically") as? Bool ?? false
let appLanguageRaw = userDefaults.string(forKey: "appLanguage")
let agentSessionsEnabled = userDefaults.object(forKey: "agentSessionsEnabled") as? Bool ?? false
let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? ""
return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
refreshAllProvidersOnMenuOpen: refreshAllProvidersOnMenuOpen,
launchAtLogin: launchAtLogin,
debugMenuEnabled: debugMenuEnabled,
debugDisableKeychainAccess: debugDisableKeychainAccess,
debugFileLoggingEnabled: debugFileLoggingEnabled,
debugLogLevelRaw: debugLogLevelRaw,
debugLoadingPatternRaw: debugLoadingPatternRaw,
debugKeepCLISessionsAlive: debugKeepCLISessionsAlive,
statusChecksEnabled: notificationDefaults.statusChecksEnabled,
sessionQuotaNotificationsEnabled: notificationDefaults.sessionQuotaNotificationsEnabled,
quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled,
predictivePaceWarningNotificationsEnabled: notificationDefaults.predictivePaceWarningNotificationsEnabled,
quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw,
quotaWarningSessionThresholdsRaw: quotaWarnings.sessionThresholdsRaw,
quotaWarningWeeklyThresholdsRaw: quotaWarnings.weeklyThresholdsRaw,
quotaWarningSessionEnabled: quotaWarnings.sessionEnabled,
quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled,
quotaWarningSoundEnabled: quotaWarnings.soundEnabled,
quotaWarningOnScreenAlertEnabled: quotaWarnings.onScreenAlertEnabled,
quotaWarningMarkersVisible: quotaWarningMarkersVisible,
weeklyProgressWorkDays: weeklyProgressWorkDays,
usageBarsShowUsed: usageBarsShowUsed,
resetTimesShowAbsolute: resetTimesShowAbsolute,
providerChangelogLinksEnabled: providerChangelogLinksEnabled,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
menuBarHidesCritters: menuBarHidesCritters,
menuBarDisplayModeRaw: menuBarDisplayModeRaw,
menuBarShowsResetTimeWhenExhausted: menuBarShowsResetTimeWhenExhausted,
kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw,
historicalTrackingEnabled: historicalTrackingEnabled,
multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw,
menuBarMetricPreferencesRaw: resolvedPreferences,
copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled,
copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw,
costUsageEnabled: costUsageEnabled,
costUsageHistoryDays: costUsageHistoryDays,
costComparisonPeriodsEnabled: costComparisonPeriodsEnabled,
costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw,
hidePersonalInfo: hidePersonalInfo,
randomBlinkEnabled: randomBlinkEnabled,
confettiOnSessionLimitResetsEnabled: confettiOnReset.session,
confettiOnWeeklyLimitResetsEnabled: confettiOnReset.weekly,
menuBarShowsHighestUsage: menuBarShowsHighestUsage,
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
codexSparkUsageVisible: codexSparkUsageVisible,
openAIWebAccessEnabled: openAIWebAccessEnabled,
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
providerStorageFootprintsEnabled: providerStorageFootprintsEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview,
mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw,
selectedMenuProviderRaw: selectedMenuProviderRaw,
providerDetectionCompleted: providerDetectionCompleted,
providersSortedAlphabetically: providersSortedAlphabetically,
appLanguageRaw: appLanguageRaw,
terminalAppRaw: userDefaults.string(forKey: "terminalApp"),
agentSessionsEnabled: agentSessionsEnabled,
agentSessionsManualHosts: agentSessionsManualHosts)
}
private static func loadNotificationDefaults(userDefaults: UserDefaults) -> NotificationDefaults {
NotificationDefaults(
statusChecksEnabled: userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true,
sessionQuotaNotificationsEnabled: self.loadSessionQuotaNotificationsDefault(userDefaults: userDefaults),
predictivePaceWarningNotificationsEnabled: userDefaults.object(
forKey: "predictivePaceWarningNotificationsEnabled") as? Bool ?? false)
}
private static func loadCostSummaryDisplayStyleRaw(
userDefaults: UserDefaults,
costUsageEnabled: Bool) -> String
{
if let storedCostSummaryDisplayStyle = userDefaults.string(forKey: "costSummaryDisplayStyle"),
CostSummaryDisplayStyle(rawValue: storedCostSummaryDisplayStyle) != nil
{
return storedCostSummaryDisplayStyle
}
let migratedStyle = CostSummaryDisplayStyle.both.rawValue
if costUsageEnabled || userDefaults.object(forKey: "costSummaryDisplayStyle") != nil {
userDefaults.set(migratedStyle, forKey: "costSummaryDisplayStyle")
}
return migratedStyle
}
private static func loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: UserDefaults) -> String? {
let key = "claudeOAuthKeychainReadStrategy"
guard let raw = userDefaults.string(forKey: key) else { return nil }
guard let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) else { return raw }
guard strategy == .securityCLIExperimental else { return raw }
let migrated = ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue
userDefaults.set(migrated, forKey: key)
let promptModeKey = "claudeOAuthKeychainPromptMode"
if userDefaults.string(forKey: promptModeKey) == nil {
userDefaults.set(ClaudeOAuthKeychainPromptMode.never.rawValue, forKey: promptModeKey)
}
return migrated
}
private static func loadConfettiOnResetDefaults(userDefaults: UserDefaults) -> (session: Bool, weekly: Bool) {
(
session: userDefaults.object(forKey: "confettiOnSessionLimitResetsEnabled") as? Bool ?? false,
weekly: userDefaults.object(forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false)
}
private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] {
let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:]
let preferences: [String: String] = if !storedPreferences.isEmpty {
storedPreferences
} else if let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"),
let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw)
{
Dictionary(
uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) })
} else {
[:]
}
let migrationKey = "antigravityTwoPoolMetricPreferenceMigrated"
guard !userDefaults.bool(forKey: migrationKey) else { return preferences }
// Tagged builds through v0.35 used primary=Claude, secondary=Gemini Pro,
// and tertiary=Gemini Flash. Remap those meanings once to the two-pool schema.
var migrated = preferences
switch MenuBarMetricPreference(rawValue: migrated[UsageProvider.antigravity.rawValue] ?? "") {
case .primary:
migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.secondary.rawValue
case .secondary:
migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.primary.rawValue
case .tertiary:
migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.primary.rawValue
case .automatic, .primaryAndSecondary, .extraUsage, .average, .monthlyPlan, .none:
break
}
userDefaults.set(migrated, forKey: "menuBarMetricPreferences")
userDefaults.set(true, forKey: migrationKey)
return migrated
}
private static func loadMultiAccountMenuLayoutRaw(userDefaults: UserDefaults) -> String {
if let layout = userDefaults.string(forKey: "multiAccountMenuLayout") {
return layout
}
let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false
return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue
}
private static func loadCopilotIconSecondaryWindowIDRaw(userDefaults: UserDefaults) -> String {
userDefaults.string(forKey: "copilotIconSecondaryWindowID") ?? CopilotIconSecondaryWindowSelection.chat
}
private static func loadDebugDisableKeychainAccess(userDefaults: UserDefaults) -> Bool {
if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool {
return stored
}
if Self.shouldBridgeSharedDefaults(for: userDefaults),
let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool
{
if Self.isRunningTests {
userDefaults.set(shared, forKey: "debugDisableKeychainAccess")
}
return shared
}
return false
}
private struct LoadedQuotaWarningDefaults {
var notificationsEnabled: Bool
var thresholdsRaw: [Int]
var sessionThresholdsRaw: [Int]
var weeklyThresholdsRaw: [Int]
var sessionEnabled: Bool
var weeklyEnabled: Bool
var soundEnabled: Bool
var onScreenAlertEnabled: Bool
}
private static func loadSessionQuotaNotificationsDefault(userDefaults: UserDefaults) -> Bool {
let stored = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool
if Self.isRunningTests, stored == nil {
userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled")
}
return stored ?? true
}
private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults {
let notificationsEnabled = userDefaults.object(forKey: "quotaWarningNotificationsEnabled") as? Bool ?? false
let rawThresholds = userDefaults.array(forKey: "quotaWarningThresholds") as? [Int]
let thresholdsRaw = QuotaWarningThresholds.sanitized(rawThresholds ?? QuotaWarningThresholds.defaults)
if Self.isRunningTests, rawThresholds != thresholdsRaw {
userDefaults.set(thresholdsRaw, forKey: "quotaWarningThresholds")
}
let rawSessionThresholds = userDefaults.array(forKey: "quotaWarningSessionThresholds") as? [Int]
let sessionThresholdsRaw = QuotaWarningThresholds.sanitized(rawSessionThresholds ?? thresholdsRaw)
if Self.isRunningTests, rawSessionThresholds != sessionThresholdsRaw {
userDefaults.set(sessionThresholdsRaw, forKey: "quotaWarningSessionThresholds")
}
let rawWeeklyThresholds = userDefaults.array(forKey: "quotaWarningWeeklyThresholds") as? [Int]
let weeklyThresholdsRaw = QuotaWarningThresholds.sanitized(rawWeeklyThresholds ?? thresholdsRaw)
if Self.isRunningTests, rawWeeklyThresholds != weeklyThresholdsRaw {
userDefaults.set(weeklyThresholdsRaw, forKey: "quotaWarningWeeklyThresholds")
}
let sessionDefault = userDefaults.object(forKey: "quotaWarningSessionEnabled") as? Bool
let sessionEnabled = sessionDefault ?? true
if Self.isRunningTests, sessionDefault == nil {
userDefaults.set(true, forKey: "quotaWarningSessionEnabled")
}
let weeklyDefault = userDefaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool
let weeklyEnabled = weeklyDefault ?? true
if Self.isRunningTests, weeklyDefault == nil {
userDefaults.set(true, forKey: "quotaWarningWeeklyEnabled")
}
let soundDefault = userDefaults.object(forKey: "quotaWarningSoundEnabled") as? Bool
let soundEnabled = soundDefault ?? true
if Self.isRunningTests, soundDefault == nil {
userDefaults.set(true, forKey: "quotaWarningSoundEnabled")
}
let onScreenAlertDefault = userDefaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool
let onScreenAlertEnabled = onScreenAlertDefault ?? false
if Self.isRunningTests, onScreenAlertDefault == nil {
userDefaults.set(false, forKey: "quotaWarningOnScreenAlertEnabled")
}
return LoadedQuotaWarningDefaults(
notificationsEnabled: notificationsEnabled,
thresholdsRaw: thresholdsRaw,
sessionThresholdsRaw: sessionThresholdsRaw,
weeklyThresholdsRaw: weeklyThresholdsRaw,
sessionEnabled: sessionEnabled,
weeklyEnabled: weeklyEnabled,
soundEnabled: soundEnabled,
onScreenAlertEnabled: onScreenAlertEnabled)
}
}
extension SettingsStore {
var configSnapshot: CodexBarConfig {
_ = self.configRevision
return self.config
}
func updateProviderState(config: CodexBarConfig) {
let rawOrder = config.providers.map(\.id.rawValue)
self.providerOrder = Self.effectiveProviderOrder(raw: rawOrder)
let metadata = ProviderDescriptorRegistry.metadata
var enablement: [UsageProvider: Bool] = [:]
enablement.reserveCapacity(metadata.count)
for provider in UsageProvider.allCases {
let defaultEnabled = metadata[provider]?.defaultEnabled ?? false
let providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider)
let isEnabled = providerConfig.enabled ?? defaultEnabled
if let previous = self.providerEnablement[provider], previous != isEnabled {
self.providerEnablementRevisions[provider, default: 0] &+= 1
}
let fingerprint = Self.providerConfigFingerprint(providerConfig)
if let previous = self.providerConfigFingerprints[provider], previous != fingerprint {
self.providerConfigRevisions[provider, default: 0] &+= 1
}
self.providerConfigFingerprints[provider] = fingerprint
enablement[provider] = isEnabled
}
self.providerEnablement = enablement
}
private static func providerConfigFingerprint(_ config: ProviderConfig) -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return (try? encoder.encode(config)) ?? Data()
}
func providerEnablementRevision(for provider: UsageProvider) -> UInt64 {
self.providerEnablementRevisions[provider, default: 0]
}
func providerConfigRevision(for provider: UsageProvider) -> UInt64 {
self.providerConfigRevisions[provider, default: 0]
}
func orderedProviders() -> [UsageProvider] {
if self.providerOrder.isEmpty {
self.updateProviderState(config: self.configSnapshot)
}
return self.providerOrder
}
func moveProvider(fromOffsets: IndexSet, toOffset: Int) {
var order = self.orderedProviders()
order.move(fromOffsets: fromOffsets, toOffset: toOffset)
self.setProviderOrder(order)
}
func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool {
self.providerEnablement[provider] ?? metadata.defaultEnabled
}
func isProviderEnabledCached(
provider: UsageProvider,
metadataByProvider: [UsageProvider: ProviderMetadata]) -> Bool
{
let defaultEnabled = metadataByProvider[provider]?.defaultEnabled ?? false
return self.providerEnablement[provider] ?? defaultEnabled
}
func enabledProvidersOrdered(metadataByProvider: [UsageProvider: ProviderMetadata]) -> [UsageProvider] {
_ = metadataByProvider
return self.orderedProviders().filter { self.providerEnablement[$0] ?? false }
}
func setProviderEnabled(provider: UsageProvider, metadata _: ProviderMetadata, enabled: Bool) {
CodexBarLog.logger(LogCategories.settings).debug(
"Provider toggle updated",
metadata: ["provider": provider.rawValue, "enabled": "\(enabled)"])
self.updateProviderConfig(provider: provider) { entry in
entry.enabled = enabled
}
if !enabled, self.selectedMenuProvider == provider {
self.selectedMenuProvider = nil
}
}
func rerunProviderDetection() {
self.runInitialProviderDetectionIfNeeded(force: true)
}
}
extension SettingsStore {
private static func effectiveProviderOrder(raw: [String]) -> [UsageProvider] {
var seen: Set<UsageProvider> = []
var ordered: [UsageProvider] = []
for rawValue in raw {
guard let provider = UsageProvider(rawValue: rawValue) else { continue }
guard !seen.contains(provider) else { continue }
seen.insert(provider)
ordered.append(provider)
}
if ordered.isEmpty {
ordered = UsageProvider.allCases
seen = Set(ordered)
}
if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {
ordered.insert(.factory, at: zaiIndex)
seen.insert(.factory)
}
if !seen.contains(.minimax), let zaiIndex = ordered.firstIndex(of: .zai) {
let insertIndex = ordered.index(after: zaiIndex)
ordered.insert(.minimax, at: insertIndex)
seen.insert(.minimax)
}
for provider in UsageProvider.allCases where !seen.contains(provider) {
ordered.append(provider)
}
return ordered
}
}