diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4c65efc..f7daafe7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index a34629766..36f303a3a 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -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 @@ -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 { @@ -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) } return config.normalized() @@ -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) @@ -296,6 +313,7 @@ struct CodexBarConfigMigrator { try stores.ampCookieStore.storeCookieHeader(nil) } catch { log.error("Failed to clear legacy secrets: \(error)") + success = false } if sawAccounts { @@ -304,6 +322,8 @@ struct CodexBarConfigMigrator { try? FileManager.default.removeItem(at: legacyURL) } } + + return success } private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig { diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index d2bc2d844..58d95475e 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -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) { diff --git a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift new file mode 100644 index 000000000..03ffc9a48 --- /dev/null +++ b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift @@ -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 +}