Skip to content

Commit ac24106

Browse files
willytop8claudesteipete
authored
fix: restore menu bar icon on macOS 26.4 (#805) (#849)
* fix: restore menu bar icon on macOS 26.4 (#805) Two changes addressing the invisible status item icon on macOS 26.4: 1. Replace compositingGroup + blendMode(.destinationOut) in UsageProgressBar with a single Canvas that draws track, fill, and pace-tip punch-out entirely inside Core Graphics. SwiftUI compositing modifiers (.compositingGroup, .blendMode as a view modifier) trigger Metal/RenderBox shader compilation; on macOS 26.4 that compilation can fail with a precondition error, making the NSStatusItem window invisible. Moving the blend to the GraphicsContext API inside the Canvas avoids the Metal path entirely. 2. Gate migrateLegacySecrets and migrateLegacyAccounts behind existing == nil in CodexBarConfigMigrator.loadOrMigrate(). These ran unconditionally on every launch, performing ~28 SecItemCopyMatching calls on the main thread. After the first launch the config is the source of truth and legacy stores are cleared, so subsequent calls all hit errSecItemNotFound — but macOS 26.4 Performance Diagnostics still emits a fault per call, adding startup jank. applyLegacyCookieSources (cheap UserDefaults reads) remains unconditional. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use completion flag for legacy migration instead of existing == nil Replaces the `existing == nil` gate added in the previous commit with a UserDefaults boolean flag (`codexbar.legacySecretsMigrationCompleted`). The `existing == nil` gate had a blind spot: if the app crashed after configStore.save() but before clearLegacyStores(), the config would exist on the next launch (`existing != nil`) so migration would be skipped and orphaned Keychain items would never be cleaned up. The flag approach is safe for all cases: - Fresh install: flag absent → migration runs → items found → clear → set flag - Normal re-launch: flag present → skip (0 Keychain calls) - Crash-interrupted first migration: flag absent → migration re-runs → items still present → clear → set flag (data already in config via setIfEmpty) - No legacy data ever existed: flag absent → migration runs → finds nothing → set flag → never scans again Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add changelog entry for #805 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: only mark migration complete when clearLegacyStores succeeds clearLegacyStores catches keychain write failures internally and returns normally, so the completion flag was being set even on partial cleanup. A transient keychain error would permanently suppress retry on next launch. - Make clearLegacyStores return Bool (true = all deletes succeeded) - Gate legacyMigrationCompletedKey on that return value - Remove comment block above the key constant (SwiftFormat docComments lint) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: cover legacy migration completion flag --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 3f5cf40 commit ac24106

4 files changed

Lines changed: 230 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
- 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!
4242
- Codex: stop falling back from app-server RPC to bare CLI TUI during automatic usage refreshes, preventing unexpected OpenAI auth browser tabs.
4343
- Menu/keychain: block delayed test-time menu mutations after teardown and enforce no-UI keychain reads more reliably (#381). Thanks @artuskg!
44+
- 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!
4445

4546
## 0.23 — 2026-04-26
4647

Sources/CodexBar/Config/CodexBarConfigMigrator.swift

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ struct CodexBarConfigMigrator {
2020
let tokenAccountStore: any ProviderTokenAccountStoring
2121
}
2222

23+
private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"
24+
2325
private struct MigrationState {
2426
var didUpdate = false
2527
var sawLegacySecrets = false
@@ -36,13 +38,21 @@ struct CodexBarConfigMigrator {
3638
var config = (existing ?? CodexBarConfig.makeDefault()).normalized()
3739
var state = MigrationState()
3840

39-
if existing == nil {
40-
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
41-
}
42-
41+
// applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so
42+
// newly-added cookie-source keys are picked up on every launch.
4343
self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state)
44-
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
45-
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)
44+
45+
let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey)
46+
if !migrationCompleted {
47+
// Run once: migrate Keychain/file secrets then clear them. Using a completion flag rather
48+
// than `existing == nil` ensures a crash between config-save and clearLegacyStores can
49+
// finish cleanup on the next launch without re-doing the (already-saved) data migration.
50+
if existing == nil {
51+
self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state)
52+
}
53+
self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state)
54+
self.migrateLegacyAccounts(stores: stores, config: &config, state: &state)
55+
}
4656

4757
if state.didUpdate {
4858
do {
@@ -53,7 +63,12 @@ struct CodexBarConfigMigrator {
5363
}
5464

5565
if state.sawLegacySecrets || state.sawLegacyAccounts {
56-
self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
66+
let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log)
67+
if cleared {
68+
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
69+
}
70+
} else if !migrationCompleted {
71+
userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey)
5772
}
5873

5974
return config.normalized()
@@ -274,11 +289,13 @@ struct CodexBarConfigMigrator {
274289
return false
275290
}
276291

292+
@discardableResult
277293
private static func clearLegacyStores(
278294
stores: LegacyStores,
279295
sawAccounts: Bool,
280-
log: CodexBarLogger)
296+
log: CodexBarLogger) -> Bool
281297
{
298+
var success = true
282299
do {
283300
try stores.zaiTokenStore.storeToken(nil)
284301
try stores.syntheticTokenStore.storeToken(nil)
@@ -296,6 +313,7 @@ struct CodexBarConfigMigrator {
296313
try stores.ampCookieStore.storeCookieHeader(nil)
297314
} catch {
298315
log.error("Failed to clear legacy secrets: \(error)")
316+
success = false
299317
}
300318

301319
if sawAccounts {
@@ -304,6 +322,8 @@ struct CodexBarConfigMigrator {
304322
try? FileManager.default.removeItem(at: legacyURL)
305323
}
306324
}
325+
326+
return success
307327
}
308328

309329
private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig {

Sources/CodexBar/UsageProgressBar.swift

Lines changed: 48 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -39,80 +39,66 @@ struct UsageProgressBar: View {
3939
}
4040

4141
var body: some View {
42-
GeometryReader { proxy in
42+
// Draw the entire progress bar — track, fill, and pace-tip punch-out — in a single Canvas.
43+
// A single Canvas uses Core Graphics internally and avoids the SwiftUI compositing modifiers
44+
// (.compositingGroup, .blendMode) that trigger Metal/RenderBox shader compilation on macOS 26.x,
45+
// which caused the status item icon to disappear (issue #805).
46+
Canvas { context, size in
4347
let scale = max(self.displayScale, 1)
44-
let fillWidth = proxy.size.width * self.clamped / 100
45-
let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100
46-
let tipWidth = max(25, proxy.size.height * 6.5)
48+
let fillWidth = size.width * self.clamped / 100
49+
let paceWidth = size.width * Self.clampedPercent(self.pacePercent) / 100
50+
let tipWidth = max(25, size.height * 6.5)
4751
let stripeInset = 1 / scale
4852
let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset
4953
let showTip = self.pacePercent != nil && tipWidth > 0.5
50-
let needsPunchCompositing = showTip
51-
let bar = ZStack(alignment: .leading) {
52-
Capsule()
53-
.fill(MenuHighlightStyle.progressTrack(self.isHighlighted))
54-
self.actualBar(width: fillWidth)
55-
if showTip {
56-
self.paceTip(width: tipWidth)
57-
.offset(x: tipOffset)
58-
}
59-
}
60-
.clipped()
61-
if self.isHighlighted {
62-
bar
63-
.compositingGroup()
64-
} else if needsPunchCompositing {
65-
bar
66-
.compositingGroup()
67-
} else {
68-
bar
69-
}
70-
}
71-
.frame(height: 6)
72-
.accessibilityLabel(self.accessibilityLabel)
73-
.accessibilityValue("\(Int(self.clamped)) percent")
74-
}
75-
76-
private func actualBar(width: CGFloat) -> some View {
77-
Capsule()
78-
.fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))
79-
.frame(width: width)
80-
.contentShape(Rectangle())
81-
.allowsHitTesting(false)
82-
}
8354

84-
private func paceTip(width: CGFloat) -> some View {
85-
let isDeficit = self.paceOnTop == false
86-
let useDeficitRed = isDeficit && self.isHighlighted == false
87-
return GeometryReader { proxy in
88-
let size = proxy.size
55+
let cornerRadius = size.height / 2
56+
let cornerSize = CGSize(width: cornerRadius, height: cornerRadius)
8957
let rect = CGRect(origin: .zero, size: size)
90-
let scale = max(self.displayScale, 1)
91-
let stripes = Self.paceStripePaths(size: size, scale: scale)
92-
let stripeColor: Color = if self.isHighlighted {
93-
.white
94-
} else if useDeficitRed {
95-
.red
96-
} else {
97-
.green
58+
59+
context.clip(to: Path(rect))
60+
61+
// Track
62+
let trackPath = Path { p in p.addRoundedRect(in: rect, cornerSize: cornerSize) }
63+
context.fill(trackPath, with: .color(MenuHighlightStyle.progressTrack(self.isHighlighted)))
64+
65+
// Fill
66+
if fillWidth > 0 {
67+
let fillRect = CGRect(x: 0, y: 0, width: min(fillWidth, size.width), height: size.height)
68+
let fillPath = Path { p in p.addRoundedRect(in: fillRect, cornerSize: cornerSize) }
69+
context.fill(
70+
fillPath,
71+
with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint)))
9872
}
9973

100-
ZStack {
101-
Canvas { context, _ in
102-
context.clip(to: Path(rect))
103-
context.fill(stripes.punched, with: .color(.white.opacity(0.9)))
74+
// Pace tip: punch-out + center stripe drawn within the canvas context using Core Graphics
75+
// blend modes so no SwiftUI compositing modifier (.blendMode, .compositingGroup) is needed.
76+
if showTip {
77+
let isDeficit = self.paceOnTop == false
78+
let useDeficitRed = isDeficit && self.isHighlighted == false
79+
let stripeColor: Color = if self.isHighlighted {
80+
.white
81+
} else if useDeficitRed {
82+
.red
83+
} else {
84+
.green
10485
}
105-
.blendMode(.destinationOut)
10686

107-
Canvas { context, _ in
108-
context.clip(to: Path(rect))
109-
context.fill(stripes.center, with: .color(stripeColor))
110-
}
87+
let tipSize = CGSize(width: tipWidth, height: size.height)
88+
let stripes = Self.paceStripePaths(size: tipSize, scale: scale)
89+
let shift = CGAffineTransform(translationX: tipOffset, y: 0)
90+
91+
// Punch out of the accumulated track+fill pixels.
92+
context.blendMode = .destinationOut
93+
context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(0.9)))
94+
context.blendMode = .normal
95+
96+
context.fill(stripes.center.applying(shift), with: .color(stripeColor))
11197
}
11298
}
113-
.frame(width: width)
114-
.contentShape(Rectangle())
115-
.allowsHitTesting(false)
99+
.frame(height: 6)
100+
.accessibilityLabel(self.accessibilityLabel)
101+
.accessibilityValue("\(Int(self.clamped)) percent")
116102
}
117103

118104
private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import CodexBarCore
2+
import Foundation
3+
import Testing
4+
@testable import CodexBar
5+
6+
@Suite(.serialized)
7+
struct CodexBarConfigMigratorTests {
8+
@Test
9+
func `legacy secret migration completion flag skips repeated scans`() throws {
10+
let suite = "CodexBarConfigMigratorTests-skip-\(UUID().uuidString)"
11+
let defaults = try #require(UserDefaults(suiteName: suite))
12+
defaults.removePersistentDomain(forName: suite)
13+
defer { defaults.removePersistentDomain(forName: suite) }
14+
15+
let secrets = CountingLegacySecretStore()
16+
let accountStore = CountingTokenAccountStore()
17+
let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore)
18+
let configStore = testConfigStore(suiteName: suite)
19+
20+
_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)
21+
22+
let firstSecretLoads = secrets.loadCount
23+
let firstAccountLoads = accountStore.loadCount
24+
#expect(firstSecretLoads > 0)
25+
#expect(firstAccountLoads == 1)
26+
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true)
27+
28+
_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)
29+
30+
#expect(secrets.loadCount == firstSecretLoads)
31+
#expect(accountStore.loadCount == firstAccountLoads)
32+
}
33+
34+
@Test
35+
func `legacy migration completion waits for successful cleanup`() throws {
36+
let suite = "CodexBarConfigMigratorTests-cleanup-failure-\(UUID().uuidString)"
37+
let defaults = try #require(UserDefaults(suiteName: suite))
38+
defaults.removePersistentDomain(forName: suite)
39+
defer { defaults.removePersistentDomain(forName: suite) }
40+
41+
let secrets = CountingLegacySecretStore(token: "legacy-token", throwOnStore: true)
42+
let accountStore = CountingTokenAccountStore()
43+
let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore)
44+
let configStore = testConfigStore(suiteName: suite)
45+
46+
_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)
47+
48+
let firstSecretLoads = secrets.loadCount
49+
#expect(firstSecretLoads > 0)
50+
#expect(secrets.clearAttempts > 0)
51+
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false)
52+
53+
secrets.throwOnStore = false
54+
_ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores)
55+
56+
#expect(secrets.loadCount > firstSecretLoads)
57+
#expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true)
58+
}
59+
60+
private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted"
61+
62+
private static func legacyStores(
63+
secrets: CountingLegacySecretStore,
64+
accountStore: CountingTokenAccountStore) -> CodexBarConfigMigrator.LegacyStores
65+
{
66+
CodexBarConfigMigrator.LegacyStores(
67+
zaiTokenStore: secrets,
68+
syntheticTokenStore: secrets,
69+
codexCookieStore: secrets,
70+
claudeCookieStore: secrets,
71+
cursorCookieStore: secrets,
72+
opencodeCookieStore: secrets,
73+
factoryCookieStore: secrets,
74+
minimaxCookieStore: secrets,
75+
minimaxAPITokenStore: secrets,
76+
kimiTokenStore: secrets,
77+
kimiK2TokenStore: secrets,
78+
augmentCookieStore: secrets,
79+
ampCookieStore: secrets,
80+
copilotTokenStore: secrets,
81+
tokenAccountStore: accountStore)
82+
}
83+
}
84+
85+
private final class CountingLegacySecretStore: ZaiTokenStoring, SyntheticTokenStoring, CookieHeaderStoring,
86+
MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, KimiK2TokenStoring, CopilotTokenStoring,
87+
@unchecked Sendable
88+
{
89+
private let lock = NSLock()
90+
private var token: String?
91+
var throwOnStore: Bool
92+
private(set) var loadCount = 0
93+
private(set) var clearAttempts = 0
94+
95+
init(token: String? = nil, throwOnStore: Bool = false) {
96+
self.token = token
97+
self.throwOnStore = throwOnStore
98+
}
99+
100+
func loadToken() throws -> String? {
101+
self.lock.lock()
102+
defer { self.lock.unlock() }
103+
self.loadCount += 1
104+
return self.token
105+
}
106+
107+
func storeToken(_ token: String?) throws {
108+
try self.store(token)
109+
}
110+
111+
func loadCookieHeader() throws -> String? {
112+
self.lock.lock()
113+
defer { self.lock.unlock() }
114+
self.loadCount += 1
115+
return self.token
116+
}
117+
118+
func storeCookieHeader(_ header: String?) throws {
119+
try self.store(header)
120+
}
121+
122+
private func store(_ value: String?) throws {
123+
self.lock.lock()
124+
defer { self.lock.unlock() }
125+
self.clearAttempts += value == nil ? 1 : 0
126+
if self.throwOnStore {
127+
throw TestStoreError.storeFailed
128+
}
129+
self.token = value
130+
}
131+
}
132+
133+
private final class CountingTokenAccountStore: ProviderTokenAccountStoring, @unchecked Sendable {
134+
private let lock = NSLock()
135+
private(set) var loadCount = 0
136+
137+
func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData] {
138+
self.lock.lock()
139+
defer { self.lock.unlock() }
140+
self.loadCount += 1
141+
return [:]
142+
}
143+
144+
func storeAccounts(_: [UsageProvider: ProviderTokenAccountData]) throws {}
145+
146+
func ensureFileExists() throws -> URL {
147+
FileManager.default.temporaryDirectory.appendingPathComponent("codexbar-empty-accounts.json")
148+
}
149+
}
150+
151+
private enum TestStoreError: Error {
152+
case storeFailed
153+
}

0 commit comments

Comments
 (0)