869 lines
40 KiB
Swift
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
|
|
}
|
|
}
|