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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
- Codex: prefer session turn-context model metadata when calculating local cost history so GPT-5.4 sessions are not bucketed as GPT-5 (#620). Thanks @betive37!
- Codex: stop falling back from app-server RPC to bare CLI TUI during automatic usage refreshes, preventing unexpected OpenAI auth browser tabs.
- Menu/keychain: block delayed test-time menu mutations after teardown and enforce no-UI keychain reads more reliably (#381). Thanks @artuskg!
- Menu bar: fix invisible status item icon on macOS 26.4 by removing remaining RenderBox-triggering SwiftUI compositing modifiers from `UsageProgressBar` (rewritten as a single Canvas) and eliminating ~28 redundant Keychain reads on every launch after the first-run migration (#805). Thanks @willytop8!

## 0.23 — 2026-04-26

Expand Down
36 changes: 28 additions & 8 deletions Sources/CodexBar/Config/CodexBarConfigMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct CodexBarConfigMigrator {
let tokenAccountStore: any ProviderTokenAccountStoring
}

private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"

private struct MigrationState {
var didUpdate = false
var sawLegacySecrets = false
Expand All @@ -36,13 +38,21 @@ struct CodexBarConfigMigrator {
var config = (existing ?? CodexBarConfig.makeDefault()).normalized()
var state = MigrationState()

if existing == nil {
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
}

// applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so
// newly-added cookie-source keys are picked up on every launch.
self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state)
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)

let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey)
if !migrationCompleted {
// Run once: migrate Keychain/file secrets then clear them. Using a completion flag rather
// than `existing == nil` ensures a crash between config-save and clearLegacyStores can
// finish cleanup on the next launch without re-doing the (already-saved) data migration.
if existing == nil {
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
}
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)
}

if state.didUpdate {
do {
Expand All @@ -53,7 +63,12 @@ struct CodexBarConfigMigrator {
}

if state.sawLegacySecrets || state.sawLegacyAccounts {
self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
if cleared {
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
}
} else if !migrationCompleted {
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
Comment on lines +70 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid finalizing migration after transient keychain read errors

Setting legacySecretsMigrationCompleted in this else branch permanently disables future migration attempts even when the current run failed to read legacy data. migrateLegacySecrets/migrateLegacyAccounts use try?, so transient keychain/file errors (for example, locked keychain returning errSecInteractionNotAllowed) are treated the same as “no legacy data,” leaving sawLegacy* false and triggering this path. In that case, users can lose one-time migration of existing credentials/accounts because subsequent launches skip migration entirely.

Useful? React with 👍 / 👎.

}

return config.normalized()
Expand Down Expand Up @@ -274,11 +289,13 @@ struct CodexBarConfigMigrator {
return false
}

@discardableResult
private static func clearLegacyStores(
stores: LegacyStores,
sawAccounts: Bool,
log: CodexBarLogger)
log: CodexBarLogger) -> Bool
{
var success = true
do {
try stores.zaiTokenStore.storeToken(nil)
try stores.syntheticTokenStore.storeToken(nil)
Expand All @@ -296,6 +313,7 @@ struct CodexBarConfigMigrator {
try stores.ampCookieStore.storeCookieHeader(nil)
} catch {
log.error("Failed to clear legacy secrets: \(error)")
success = false
}

if sawAccounts {
Expand All @@ -304,6 +322,8 @@ struct CodexBarConfigMigrator {
try? FileManager.default.removeItem(at: legacyURL)
}
}

return success
}

private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig {
Expand Down
110 changes: 48 additions & 62 deletions Sources/CodexBar/UsageProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,80 +39,66 @@ struct UsageProgressBar: View {
}

var body: some View {
GeometryReader { proxy in
// Draw the entire progress bar — track, fill, and pace-tip punch-out — in a single Canvas.
// A single Canvas uses Core Graphics internally and avoids the SwiftUI compositing modifiers
// (.compositingGroup, .blendMode) that trigger Metal/RenderBox shader compilation on macOS 26.x,
// which caused the status item icon to disappear (issue #805).
Canvas { context, size in
let scale = max(self.displayScale, 1)
let fillWidth = proxy.size.width * self.clamped / 100
let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100
let tipWidth = max(25, proxy.size.height * 6.5)
let fillWidth = size.width * self.clamped / 100
let paceWidth = size.width * Self.clampedPercent(self.pacePercent) / 100
let tipWidth = max(25, size.height * 6.5)
let stripeInset = 1 / scale
let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset
let showTip = self.pacePercent != nil && tipWidth > 0.5
let needsPunchCompositing = showTip
let bar = ZStack(alignment: .leading) {
Capsule()
.fill(MenuHighlightStyle.progressTrack(self.isHighlighted))
self.actualBar(width: fillWidth)
if showTip {
self.paceTip(width: tipWidth)
.offset(x: tipOffset)
}
}
.clipped()
if self.isHighlighted {
bar
.compositingGroup()
} else if needsPunchCompositing {
bar
.compositingGroup()
} else {
bar
}
}
.frame(height: 6)
.accessibilityLabel(self.accessibilityLabel)
.accessibilityValue("\(Int(self.clamped)) percent")
}

private func actualBar(width: CGFloat) -> some View {
Capsule()
.fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))
.frame(width: width)
.contentShape(Rectangle())
.allowsHitTesting(false)
}

private func paceTip(width: CGFloat) -> some View {
let isDeficit = self.paceOnTop == false
let useDeficitRed = isDeficit && self.isHighlighted == false
return GeometryReader { proxy in
let size = proxy.size
let cornerRadius = size.height / 2
let cornerSize = CGSize(width: cornerRadius, height: cornerRadius)
let rect = CGRect(origin: .zero, size: size)
let scale = max(self.displayScale, 1)
let stripes = Self.paceStripePaths(size: size, scale: scale)
let stripeColor: Color = if self.isHighlighted {
.white
} else if useDeficitRed {
.red
} else {
.green

context.clip(to: Path(rect))

// Track
let trackPath = Path { p in p.addRoundedRect(in: rect, cornerSize: cornerSize) }
context.fill(trackPath, with: .color(MenuHighlightStyle.progressTrack(self.isHighlighted)))

// Fill
if fillWidth > 0 {
let fillRect = CGRect(x: 0, y: 0, width: min(fillWidth, size.width), height: size.height)
let fillPath = Path { p in p.addRoundedRect(in: fillRect, cornerSize: cornerSize) }
context.fill(
fillPath,
with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint)))
}

ZStack {
Canvas { context, _ in
context.clip(to: Path(rect))
context.fill(stripes.punched, with: .color(.white.opacity(0.9)))
// Pace tip: punch-out + center stripe drawn within the canvas context using Core Graphics
// blend modes so no SwiftUI compositing modifier (.blendMode, .compositingGroup) is needed.
if showTip {
let isDeficit = self.paceOnTop == false
let useDeficitRed = isDeficit && self.isHighlighted == false
let stripeColor: Color = if self.isHighlighted {
.white
} else if useDeficitRed {
.red
} else {
.green
}
.blendMode(.destinationOut)

Canvas { context, _ in
context.clip(to: Path(rect))
context.fill(stripes.center, with: .color(stripeColor))
}
let tipSize = CGSize(width: tipWidth, height: size.height)
let stripes = Self.paceStripePaths(size: tipSize, scale: scale)
let shift = CGAffineTransform(translationX: tipOffset, y: 0)

// Punch out of the accumulated track+fill pixels.
context.blendMode = .destinationOut
context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(0.9)))
context.blendMode = .normal

context.fill(stripes.center.applying(shift), with: .color(stripeColor))
}
}
.frame(width: width)
.contentShape(Rectangle())
.allowsHitTesting(false)
.frame(height: 6)
.accessibilityLabel(self.accessibilityLabel)
.accessibilityValue("\(Int(self.clamped)) percent")
}

private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) {
Expand Down
153 changes: 153 additions & 0 deletions Tests/CodexBarTests/CodexBarConfigMigratorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

@Suite(.serialized)
struct CodexBarConfigMigratorTests {
@Test
func `legacy secret migration completion flag skips repeated scans`() throws {
let suite = "CodexBarConfigMigratorTests-skip-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }

let secrets = CountingLegacySecretStore()
let accountStore = CountingTokenAccountStore()
let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore)
let configStore = testConfigStore(suiteName: suite)

_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)

let firstSecretLoads = secrets.loadCount
let firstAccountLoads = accountStore.loadCount
#expect(firstSecretLoads > 0)
#expect(firstAccountLoads == 1)
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true)

_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)

#expect(secrets.loadCount == firstSecretLoads)
#expect(accountStore.loadCount == firstAccountLoads)
}

@Test
func `legacy migration completion waits for successful cleanup`() throws {
let suite = "CodexBarConfigMigratorTests-cleanup-failure-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }

let secrets = CountingLegacySecretStore(token: "legacy-token", throwOnStore: true)
let accountStore = CountingTokenAccountStore()
let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore)
let configStore = testConfigStore(suiteName: suite)

_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)

let firstSecretLoads = secrets.loadCount
#expect(firstSecretLoads > 0)
#expect(secrets.clearAttempts > 0)
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false)

secrets.throwOnStore = false
_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)

#expect(secrets.loadCount > firstSecretLoads)
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true)
}

private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"

private static func legacyStores(
secrets: CountingLegacySecretStore,
accountStore: CountingTokenAccountStore) -> CodexBarConfigMigrator.LegacyStores
{
CodexBarConfigMigrator.LegacyStores(
zaiTokenStore: secrets,
syntheticTokenStore: secrets,
codexCookieStore: secrets,
claudeCookieStore: secrets,
cursorCookieStore: secrets,
opencodeCookieStore: secrets,
factoryCookieStore: secrets,
minimaxCookieStore: secrets,
minimaxAPITokenStore: secrets,
kimiTokenStore: secrets,
kimiK2TokenStore: secrets,
augmentCookieStore: secrets,
ampCookieStore: secrets,
copilotTokenStore: secrets,
tokenAccountStore: accountStore)
}
}

private final class CountingLegacySecretStore: ZaiTokenStoring, SyntheticTokenStoring, CookieHeaderStoring,
MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, KimiK2TokenStoring, CopilotTokenStoring,
@unchecked Sendable
{
private let lock = NSLock()
private var token: String?
var throwOnStore: Bool
private(set) var loadCount = 0
private(set) var clearAttempts = 0

init(token: String? = nil, throwOnStore: Bool = false) {
self.token = token
self.throwOnStore = throwOnStore
}

func loadToken() throws -> String? {
self.lock.lock()
defer { self.lock.unlock() }
self.loadCount += 1
return self.token
}

func storeToken(_ token: String?) throws {
try self.store(token)
}

func loadCookieHeader() throws -> String? {
self.lock.lock()
defer { self.lock.unlock() }
self.loadCount += 1
return self.token
}

func storeCookieHeader(_ header: String?) throws {
try self.store(header)
}

private func store(_ value: String?) throws {
self.lock.lock()
defer { self.lock.unlock() }
self.clearAttempts += value == nil ? 1 : 0
if self.throwOnStore {
throw TestStoreError.storeFailed
}
self.token = value
}
}

private final class CountingTokenAccountStore: ProviderTokenAccountStoring, @unchecked Sendable {
private let lock = NSLock()
private(set) var loadCount = 0

func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData] {
self.lock.lock()
defer { self.lock.unlock() }
self.loadCount += 1
return [:]
}

func storeAccounts(_: [UsageProvider: ProviderTokenAccountData]) throws {}

func ensureFileExists() throws -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("codexbar-empty-accounts.json")
}
}

private enum TestStoreError: Error {
case storeFailed
}