Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev!
- Codex: include archived session logs in local token cost scanning and dedupe by session id.
- Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes.
- Claude/Codex: avoid background Keychain prompts during OAuth refresh, OpenAI web cookie imports, and keychain cache reads/writes; refresh cached credentials after file/keychain changes.

### Menu & Menu Bar
- Menu: opening OpenAI web submenus triggers a refresh when the data is stale.
Expand Down
114 changes: 95 additions & 19 deletions Sources/CodexBar/KeychainMigration.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import CodexBarCore
import Foundation
import LocalAuthentication
import Security

/// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
/// to prevent permission prompts on every rebuild during development.
enum KeychainMigration {
private static let log = CodexBarLog.logger(LogCategories.keychainMigration)
private static let migrationKey = "KeychainMigrationV1Completed"
private static let claudeMigrationKey = "KeychainMigrationClaudeCredentialsV1"

struct MigrationItem: Hashable, Sendable {
let service: String
Expand All @@ -32,39 +34,64 @@ enum KeychainMigration {
MigrationItem(service: "Claude Code-credentials", account: nil),
]

/// Run migration once per installation
/// Run migration once per installation (with a Claude-specific follow-up when credentials change).
static func migrateIfNeeded() {
guard !KeychainAccessGate.isDisabled else {
self.log.info("Keychain access disabled; skipping migration")
return
}
guard !UserDefaults.standard.bool(forKey: self.migrationKey) else {
self.log.debug("Keychain migration already completed, skipping")
return
}

self.log.info("Starting keychain migration to reduce permission prompts")
if !UserDefaults.standard.bool(forKey: self.migrationKey) {
self.log.info("Starting keychain migration to reduce permission prompts")

var migratedCount = 0
var errorCount = 0
var migratedCount = 0
var errorCount = 0

for item in self.itemsToMigrate {
do {
if try self.migrateItem(item) {
migratedCount += 1
for item in self.itemsToMigrate {
do {
if try self.migrateItem(item) {
migratedCount += 1
}
} catch {
errorCount += 1
self.log.error("Failed to migrate \(item.label): \(String(describing: error))")
}
} catch {
errorCount += 1
self.log.error("Failed to migrate \(item.label): \(String(describing: error))")
}

self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors")
UserDefaults.standard.set(true, forKey: self.migrationKey)

if migratedCount > 0 {
self.log.info("✅ Future rebuilds will not prompt for keychain access")
}
} else {
self.log.debug("Keychain migration already completed, skipping")
}

self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors")
UserDefaults.standard.set(true, forKey: self.migrationKey)
self.migrateClaudeCredentialsIfNeeded()
}

if migratedCount > 0 {
self.log.info("✅ Future rebuilds will not prompt for keychain access")
static func migrateClaudeCredentialsIfNeeded() {
guard let item = self.itemsToMigrate.first(where: { $0.service == "Claude Code-credentials" }) else {
return
}
guard let fingerprint = self.claudeCredentialsFingerprint() else { return }

let stored = self.loadClaudeMigrationFingerprint()
if stored == fingerprint { return }

do {
if try self.migrateItem(item) {
self.log.info("Migrated Claude credentials to ThisDeviceOnly")
} else {
self.log.debug("Claude credentials already using correct accessibility")
}
} catch {
self.log.error("Failed to migrate Claude credentials: \(String(describing: error))")
return
}

self.saveClaudeMigrationFingerprint(fingerprint)
}

/// Migrate a single keychain item to the new accessibility level
Expand Down Expand Up @@ -141,6 +168,55 @@ enum KeychainMigration {
return true
}

private struct ClaudeCredentialsFingerprint: Codable, Equatable {
let modifiedAt: Int?
let accessible: String?
}

private static func claudeCredentialsFingerprint() -> ClaudeCredentialsFingerprint? {
#if os(macOS)
if KeychainAccessGate.isDisabled { return nil }
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Claude Code-credentials",
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecUseAuthenticationContext as String: context,
]

var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let attrs = result as? [String: Any] else {
return nil
}
let modifiedAt = (attrs[kSecAttrModificationDate as String] as? Date)
.map { Int($0.timeIntervalSince1970) }
let accessible = attrs[kSecAttrAccessible as String] as? String
return ClaudeCredentialsFingerprint(modifiedAt: modifiedAt, accessible: accessible)
#else
return nil
#endif
}

private static func loadClaudeMigrationFingerprint() -> ClaudeCredentialsFingerprint? {
guard let data = UserDefaults.standard.data(forKey: self.claudeMigrationKey) else { return nil }
return try? JSONDecoder().decode(ClaudeCredentialsFingerprint.self, from: data)
}

private static func saveClaudeMigrationFingerprint(_ fingerprint: ClaudeCredentialsFingerprint) {
guard let data = try? JSONEncoder().encode(fingerprint) else { return }
UserDefaults.standard.set(data, forKey: self.claudeMigrationKey)
}

#if DEBUG
static func _resetClaudeMigrationTrackingForTesting() {
UserDefaults.standard.removeObject(forKey: self.claudeMigrationKey)
UserDefaults.standard.removeObject(forKey: self.migrationKey)
}
#endif

/// Reset migration flag (for testing)
static func resetMigrationFlag() {
UserDefaults.standard.removeObject(forKey: self.migrationKey)
Expand Down
10 changes: 5 additions & 5 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct ProvidersPane: View {
onCopyError: { text in self.copyToPasteboard(text) },
onRefresh: {
Task { @MainActor in
await self.store.refreshProvider(provider, allowDisabled: true)
await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true)
}
})
} else {
Expand Down Expand Up @@ -184,19 +184,19 @@ struct ProvidersPane: View {
setActiveIndex: { index in
self.settings.setActiveTokenAccountIndex(index, for: provider)
Task { @MainActor in
await self.store.refreshProvider(provider, allowDisabled: true)
await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true)
}
},
addAccount: { label, token in
self.settings.addTokenAccount(provider: provider, label: label, token: token)
Task { @MainActor in
await self.store.refreshProvider(provider, allowDisabled: true)
await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true)
}
},
removeAccount: { accountID in
self.settings.removeTokenAccount(provider: provider, accountID: accountID)
Task { @MainActor in
await self.store.refreshProvider(provider, allowDisabled: true)
await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true)
}
},
openConfigFile: {
Expand All @@ -205,7 +205,7 @@ struct ProvidersPane: View {
reloadFromDisk: {
self.settings.reloadTokenAccounts()
Task { @MainActor in
await self.store.refreshProvider(provider, allowDisabled: true)
await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true)
}
})
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/CodexBar/ProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
struct ProviderSpec {
let style: IconStyle
let isEnabled: @MainActor () -> Bool
let fetch: () async -> ProviderFetchOutcome
let fetch: (_ allowKeychainPrompt: Bool) async -> ProviderFetchOutcome
}

struct ProviderRegistry {
Expand Down Expand Up @@ -33,7 +33,7 @@ struct ProviderRegistry {
let spec = ProviderSpec(
style: descriptor.branding.iconStyle,
isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) },
fetch: {
fetch: { allowKeychainPrompt in
let sourceMode = ProviderCatalog.implementation(for: provider)?
.sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings))
?? .auto
Expand All @@ -51,6 +51,7 @@ struct ProviderRegistry {
let context = ProviderFetchContext(
runtime: .app,
sourceMode: sourceMode,
allowKeychainPrompt: allowKeychainPrompt,
includeCredits: false,
webTimeout: 60,
webDebugDumpHTML: false,
Expand Down
20 changes: 18 additions & 2 deletions Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ extension UsageStore {
await self.performRuntimeAction(.forceSessionRefresh, for: .augment)
}

func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async {
func refreshProvider(
_ provider: UsageProvider,
allowDisabled: Bool = false,
allowKeychainPrompt: Bool = false) async
{
guard let spec = self.providerSpecs[provider] else { return }

if !spec.isEnabled(), !allowDisabled {
Expand Down Expand Up @@ -42,7 +46,19 @@ extension UsageStore {
}
}

let outcome = await spec.fetch()
let outcome = await spec.fetch(allowKeychainPrompt)
if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() {
await MainActor.run {
self.snapshots.removeValue(forKey: .claude)
self.errors[.claude] = nil
self.lastSourceLabels.removeValue(forKey: .claude)
self.lastFetchAttempts.removeValue(forKey: .claude)
self.accountSnapshots.removeValue(forKey: .claude)
self.tokenSnapshots.removeValue(forKey: .claude)
self.tokenErrors[.claude] = nil
self.failureGates[.claude]?.reset()
}
}
await MainActor.run {
self.lastFetchAttempts[provider] = outcome.attempts
}
Expand Down
49 changes: 36 additions & 13 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ final class UsageStore {

await withTaskGroup(of: Void.self) { group in
for provider in UsageProvider.allCases {
group.addTask { await self.refreshProvider(provider) }
group.addTask { await self.refreshProvider(provider, allowKeychainPrompt: forceTokenUsage) }
group.addTask { await self.refreshStatus(provider) }
}
group.addTask { await self.refreshCreditsIfNeeded() }
Expand All @@ -440,10 +440,12 @@ final class UsageStore {

// OpenAI web scrape depends on the current Codex account email (which can change after login/account switch).
// Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage)
await self.refreshOpenAIDashboardIfNeeded(
force: forceTokenUsage,
allowKeychainPrompt: forceTokenUsage)

if self.openAIDashboardRequiresLogin {
await self.refreshProvider(.codex)
await self.refreshProvider(.codex, allowKeychainPrompt: false)
await self.refreshCreditsIfNeeded()
}

Expand Down Expand Up @@ -663,7 +665,7 @@ extension UsageStore {
if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return }
let stamp = now.formatted(date: .abbreviated, time: .shortened)
self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)")
Task { await self.refreshOpenAIDashboardIfNeeded(force: true) }
Task { await self.refreshOpenAIDashboardIfNeeded(force: true, allowKeychainPrompt: false) }
}

private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async {
Expand Down Expand Up @@ -708,7 +710,10 @@ extension UsageStore {
}
}

private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async {
private func refreshOpenAIDashboardIfNeeded(
force: Bool = false,
allowKeychainPrompt: Bool = false) async
{
guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else {
self.resetOpenAIWebState()
return
Expand Down Expand Up @@ -754,7 +759,8 @@ extension UsageStore {
// user.
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
targetEmail: targetEmail,
force: true)
force: true,
allowKeychainPrompt: allowKeychainPrompt)
{
effectiveEmail = imported
}
Expand All @@ -769,7 +775,8 @@ extension UsageStore {
if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
targetEmail: targetEmail,
force: true)
force: true,
allowKeychainPrompt: allowKeychainPrompt)
{
effectiveEmail = imported
}
Expand Down Expand Up @@ -798,7 +805,11 @@ extension UsageStore {
// importing cookies from the user's browser.
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
var effectiveEmail = targetEmail
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
targetEmail: targetEmail,
force: true,
allowKeychainPrompt: allowKeychainPrompt)
{
effectiveEmail = imported
}
do {
Expand All @@ -821,7 +832,11 @@ extension UsageStore {
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
var effectiveEmail = targetEmail
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
targetEmail: targetEmail,
force: true,
allowKeychainPrompt: allowKeychainPrompt)
{
effectiveEmail = imported
}
do {
Expand Down Expand Up @@ -884,11 +899,18 @@ extension UsageStore {
func importOpenAIDashboardBrowserCookiesNow() async {
self.resetOpenAIWebDebugLog(context: "manual import")
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
_ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
await self.refreshOpenAIDashboardIfNeeded(force: true)
_ = await self.importOpenAIDashboardCookiesIfNeeded(
targetEmail: targetEmail,
force: true,
allowKeychainPrompt: true)
await self.refreshOpenAIDashboardIfNeeded(force: true, allowKeychainPrompt: true)
}

private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? {
private func importOpenAIDashboardCookiesIfNeeded(
targetEmail: String?,
force: Bool,
allowKeychainPrompt: Bool) async -> String?
{
let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true
let cookieSource = self.settings.codexCookieSource
Expand Down Expand Up @@ -942,6 +964,7 @@ extension UsageStore {
result = try await importer.importBestCookies(
intoAccountEmail: normalizedTarget,
allowAnyAccount: allowAnyAccount,
allowKeychainPrompt: allowKeychainPrompt,
logger: log)
case .off:
result = OpenAIDashboardBrowserCookieImporter.ImportResult(
Expand Down Expand Up @@ -1250,7 +1273,7 @@ extension UsageStore {
} else {
ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) { msg in lines.append(msg) }
}
let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load()) != nil
let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load(allowKeychainPrompt: false)) != nil

let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
selectedDataSource: claudeUsageDataSource,
Expand Down
Loading