From fecd09f7fbdefc0132b0f189fc30c49db9d95d8e Mon Sep 17 00:00:00 2001 From: Perry Story Date: Thu, 21 May 2026 07:20:40 -0400 Subject: [PATCH 1/2] perf: suppress redundant icon observer callbacks Remove isRefreshing from iconObservationToken and add observer-side signature guard to skip updateIcons() when icon-relevant state is unchanged. The existing render signature inside applyIcon() avoided redundant icon rendering, but not redundant observer work. This change reduces observer callback churn before render-signature checking is reached. Before: updateIcons() called 10 times per refresh cycle (1 rendered) After: updateIcons() called 6 times per refresh cycle (1 rendered) Split from #1073. Related: #678. --- .../StatusItemController+Animation.swift | 173 +++++++++++------- .../StatusItemController+IconPerf.swift | 69 +++++++ Sources/CodexBar/StatusItemController.swift | 98 ++++++++-- Sources/CodexBar/UsageStore.swift | 1 - ...tusItemIconObservationSignatureTests.swift | 89 +++++++++ 5 files changed, 346 insertions(+), 84 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+IconPerf.swift create mode 100644 Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 0e31ac0ee..af2859355 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -7,7 +7,8 @@ extension StatusItemController { private static let blinkActiveTickInterval: Duration = .milliseconds(75) private static let blinkIdleFallbackInterval: Duration = .seconds(1) static let loadingAnimationFPS: Double = 30.0 - static let loadingAnimationPhaseIncrement: Double = 2.7 / StatusItemController.loadingAnimationFPS + static let loadingAnimationPhaseIncrement: Double = + 2.7 / StatusItemController.loadingAnimationFPS private static let loadingAnimationMaxContinuousDuration: TimeInterval = 30.0 func needsMenuBarIconAnimation() -> Bool { @@ -41,7 +42,8 @@ extension StatusItemController { self.blinkTask = Task { [weak self] in while !Task.isCancelled { let delay = await MainActor.run { - self?.blinkTickSleepDuration(now: Date()) ?? Self.blinkIdleFallbackInterval + self?.blinkTickSleepDuration(now: Date()) + ?? Self.blinkIdleFallbackInterval } try? await Task.sleep(for: delay) await MainActor.run { self?.tickBlink() } @@ -56,7 +58,8 @@ extension StatusItemController { private func seedBlinkStatesIfNeeded() { let now = Date() for provider in UsageProvider.allCases where self.blinkStates[provider] == nil { - self.blinkStates[provider] = BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) + self.blinkStates[provider] = BlinkState( + nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) } } @@ -80,10 +83,13 @@ extension StatusItemController { for provider in UsageProvider.allCases { let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider) - guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) else { continue } + guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) + else { continue } - let state = self - .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) + let state = + self + .blinkStates[provider] + ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) if state.blinkStart != nil { return Self.blinkActiveTickInterval } @@ -118,13 +124,16 @@ extension StatusItemController { for provider in UsageProvider.allCases { let shouldRender = mergeIcons ? self.isEnabled(provider) : self.isVisible(provider) - guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) else { + guard shouldRender, !self.shouldAnimate(provider: provider, mergeIcons: mergeIcons) + else { self.clearMotion(for: provider) continue } - var state = self - .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) + var state = + self + .blinkStates[provider] + ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) if let pendingSecond = state.pendingSecondStart, now >= pendingSecond { state.blinkStart = now @@ -152,7 +161,8 @@ extension StatusItemController { state.blinkStart = now state.effect = self.randomEffect(for: provider) if state.effect == .blink, Double.random(in: 0...1) < doubleBlinkChance { - state.pendingSecondStart = now.addingTimeInterval(Double.random(in: doubleDelayRange)) + state.pendingSecondStart = now.addingTimeInterval( + Double.random(in: doubleDelayRange)) } self.clearMotion(for: provider) } else { @@ -223,7 +233,7 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { + func applyIcon(phase: Double?) -> Bool { // swiftlint:disable:this function_body_length guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle @@ -265,11 +275,12 @@ extension StatusItemController { surface: .menuBar, snapshotOverride: snapshot, now: snapshot?.updatedAt ?? Date()) - var credits: Double? = codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits: Double? = + codexProjection?.menuBarFallback == .creditsBalance + ? self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + : nil var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? @@ -289,7 +300,9 @@ extension StatusItemController { // Keep loading animation layout stable: IconRenderer uses `weeklyRemaining > 0` to switch layouts, // so hitting an exact 0 would flip between "normal" and "weekly exhausted" rendering. primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) - weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) + weekly = max( + pattern.value(phase: phase + pattern.secondaryOffset), + Self.loadingPercentEpsilon) credits = nil stale = false } @@ -297,7 +310,8 @@ extension StatusItemController { let blink: CGFloat = style == .combined ? 0 : self.blinkAmount(for: primaryProvider) let wiggle: CGFloat = style == .combined ? 0 : self.wiggleAmount(for: primaryProvider) - let tilt: CGFloat = style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 + let tilt: CGFloat = + style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 let statusIndicator: ProviderStatusIndicator = { for provider in self.store.enabledProvidersForDisplay() { @@ -306,11 +320,6 @@ extension StatusItemController { } return .none }() - let debugDouble: (Double?) -> String = { value in - guard let value else { return "nil" } - return String(format: "%.3f", value) - } - if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { @@ -319,9 +328,9 @@ extension StatusItemController { "mode=brandPercent", "provider=\(primaryProvider.rawValue)", "style=\(String(describing: style))", - "primary=\(debugDouble(primary))", - "weekly=\(debugDouble(weekly))", - "credits=\(debugDouble(credits))", + "primary=\(Self.iconSignatureValue(primary))", + "weekly=\(Self.iconSignatureValue(weekly))", + "credits=\(Self.iconSignatureValue(credits))", "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", "text=\(displayText ?? "nil")", @@ -329,10 +338,13 @@ extension StatusItemController { "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + self.noteIconPerfRender(skipped: true) return true } - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) + self.noteIconPerfRender(skipped: false) return false } @@ -342,33 +354,36 @@ extension StatusItemController { "mode=morph", "provider=\(primaryProvider.rawValue)", "style=\(String(describing: style))", - "morph=\(debugDouble(morphProgress))", + "morph=\(Self.iconSignatureValue(morphProgress))", "status=\(statusIndicator.rawValue)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + self.noteIconPerfRender(skipped: true) return true } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { let signature = [ "mode=icon", "provider=\(primaryProvider.rawValue)", "style=\(String(describing: style))", - "primary=\(debugDouble(primary))", - "weekly=\(debugDouble(weekly))", - "credits=\(debugDouble(credits))", + "primary=\(Self.iconSignatureValue(primary))", + "weekly=\(Self.iconSignatureValue(weekly))", + "credits=\(Self.iconSignatureValue(credits))", "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", - "blink=\(debugDouble(Double(blink)))", - "wiggle=\(debugDouble(Double(wiggle)))", - "tilt=\(debugDouble(Double(tilt)))", + "blink=\(Self.iconSignatureValue(Double(blink)))", + "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", + "tilt=\(Self.iconSignatureValue(Double(tilt)))", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + self.noteIconPerfRender(skipped: true) return true } let image = IconRenderer.makeIcon( @@ -381,8 +396,10 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator) - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } + self.noteIconPerfRender(skipped: false) return false } @@ -429,13 +446,18 @@ extension StatusItemController { "warningFlash=\(warningFlash ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + self.noteIconPerfRender(skipped: true) return true } - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) + self.noteIconPerfRender(skipped: false) return false } + self.setButtonTitle(nil, for: button) + // OpenRouter always gets a meter here — the brand-logo fallback was removed on purpose. let resolved = snapshot.map { IconRemainingResolver.resolvedPercents( @@ -467,11 +489,12 @@ extension StatusItemController { surface: .menuBar, snapshotOverride: snapshot, now: snapshot?.updatedAt ?? Date()) - var credits: Double? = codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits: Double? = + codexProjection?.menuBarFallback == .creditsBalance + ? self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + : nil var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -489,7 +512,9 @@ extension StatusItemController { } else { // Keep loading animation layout stable: IconRenderer switches layouts at `weeklyRemaining == 0`. primary = max(pattern.value(phase: phase), Self.loadingPercentEpsilon) - weekly = max(pattern.value(phase: phase + pattern.secondaryOffset), Self.loadingPercentEpsilon) + weekly = max( + pattern.value(phase: phase + pattern.secondaryOffset), + Self.loadingPercentEpsilon) credits = nil stale = false } @@ -517,10 +542,12 @@ extension StatusItemController { "loading=\(isLoading ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + self.noteIconPerfRender(skipped: true) return true } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { let signature = [ "mode=icon", @@ -538,9 +565,9 @@ extension StatusItemController { "loading=\(isLoading ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + self.noteIconPerfRender(skipped: true) return true } - self.setButtonTitle(nil, for: button) let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, @@ -551,8 +578,10 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator) - self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) + self.setButtonImage( + warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } + self.noteIconPerfRender(skipped: false) return false } @@ -652,7 +681,8 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = codexProjection?.rateWindow(for: .weekly) + let weeklyWindow = + codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary // Abacus has no secondary window; pace is computed on primary monthly credits ?? (provider == .abacus ? snapshot?.primary : nil) @@ -672,19 +702,21 @@ extension StatusItemController { let creditsRemaining = codexProjection?.credits?.remaining, creditsRemaining > 0 { - return UsageFormatter - .creditsString(from: creditsRemaining) - .replacingOccurrences(of: " left", with: "") + return + UsageFormatter + .creditsString(from: creditsRemaining) + .replacingOccurrences(of: " left", with: "") } return displayText } nonisolated static func deepSeekBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { - guard let rawValue = snapshot?.primary?.resetDescription? - .trimmingCharacters(in: .whitespacesAndNewlines), - !rawValue.isEmpty, - rawValue.hasPrefix("$") || rawValue.hasPrefix("¥") + guard + let rawValue = snapshot?.primary?.resetDescription? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty, + rawValue.hasPrefix("$") || rawValue.hasPrefix("¥") else { return nil } @@ -792,10 +824,11 @@ extension StatusItemController { { guard usage.creditsTotal > 0 else { return percentFallback } guard usage.creditsRemaining <= 0 else { return fallback } - guard usage.overagesStatus? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .hasPrefix("enabled") == true + guard + usage.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("enabled") == true else { return fallback } @@ -832,12 +865,15 @@ extension StatusItemController { let valueStart = rawValue.index(rawValue.startIndex, offsetBy: prefix.count) var value = rawValue[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) if !suffix.isEmpty, value.hasSuffix(suffix) { - value = String(value.dropLast(suffix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + value = String(value.dropLast(suffix.count)).trimmingCharacters( + in: .whitespacesAndNewlines) } return value.isEmpty ? nil : value } - private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) + -> RateWindow? + { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } @@ -890,10 +926,13 @@ extension StatusItemController { self.seedBlinkStatesIfNeeded() for provider in UsageProvider.allCases { - let shouldBlink = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) + let shouldBlink = + self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) guard shouldBlink, !self.shouldAnimate(provider: provider) else { continue } - var state = self - .blinkStates[provider] ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) + var state = + self + .blinkStates[provider] + ?? BlinkState(nextBlink: now.addingTimeInterval(BlinkState.randomDelay())) state.blinkStart = now state.pendingSecondStart = nil state.effect = self.randomEffect(for: provider) @@ -947,7 +986,9 @@ extension StatusItemController { }) self.animationDriver = driver driver.start(fps: Self.loadingAnimationFPS) - } else if let forced = self.settings.debugLoadingPattern, forced != self.animationPattern { + } else if let forced = self.settings.debugLoadingPattern, + forced != self.animationPattern + { self.animationPattern = forced self.animationPhase = 0 } @@ -1005,7 +1046,9 @@ extension StatusItemController { return image } - private nonisolated static func drawBrandStatusOverlay(indicator: ProviderStatusIndicator, size: NSSize) { + private nonisolated static func drawBrandStatusOverlay( + indicator: ProviderStatusIndicator, size: NSSize) + { guard indicator.hasIssue else { return } let color = NSColor.labelColor diff --git a/Sources/CodexBar/StatusItemController+IconPerf.swift b/Sources/CodexBar/StatusItemController+IconPerf.swift new file mode 100644 index 000000000..80ed9b0db --- /dev/null +++ b/Sources/CodexBar/StatusItemController+IconPerf.swift @@ -0,0 +1,69 @@ +import Observation + +struct IconPerfRefreshCycleMetrics { + var updateIconsCalls = 0 + var renderedCalls = 0 + var skippedCalls = 0 +} + +extension StatusItemController { + func observeIconPerfRefreshCycleChanges() { + withObservationTracking { + _ = self.store.isRefreshing + _ = self.settings.debugLogLevel + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.observeIconPerfRefreshCycleChanges() + self.handleIconPerfRefreshCycleChange() + } + } + self.handleIconPerfRefreshCycleChange() + } + + func handleIconPerfRefreshCycleChange() { + guard self.settings.isVerboseLoggingEnabled else { + self.iconPerfRefreshCycleMetrics = nil + self.iconPerfUpdatePassActive = false + return + } + guard !self.store.isRefreshing else { return } + self.logIconPerfRefreshCycleIfNeeded() + } + + func beginIconPerfUpdatePass() { + self.iconPerfUpdatePassActive = false + guard self.settings.isVerboseLoggingEnabled, self.store.isRefreshing else { return } + if self.iconPerfRefreshCycleMetrics == nil { + self.iconPerfRefreshCycleMetrics = IconPerfRefreshCycleMetrics() + } + self.iconPerfRefreshCycleMetrics?.updateIconsCalls += 1 + self.iconPerfUpdatePassActive = true + } + + func endIconPerfUpdatePass() { + self.iconPerfUpdatePassActive = false + } + + func noteIconPerfRender(skipped: Bool) { + guard self.iconPerfUpdatePassActive else { return } + if skipped { + self.iconPerfRefreshCycleMetrics?.skippedCalls += 1 + } else { + self.iconPerfRefreshCycleMetrics?.renderedCalls += 1 + } + } + + func logIconPerfRefreshCycleIfNeeded() { + guard let metrics = self.iconPerfRefreshCycleMetrics, + metrics.updateIconsCalls > 0 + else { + self.iconPerfRefreshCycleMetrics = nil + return + } + let message = "[perf] refresh cycle: updateIcons() called \(metrics.updateIconsCalls) times " + + "(\(metrics.renderedCalls) rendered, \(metrics.skippedCalls) skipped)" + self.menuLogger.verbose(message) + self.iconPerfRefreshCycleMetrics = nil + } +} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index db0ec82fd..6f7d2bf91 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -34,14 +34,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.menuRefreshEnabled = self.defaultMenuRefreshEnabled } #endif - typealias Factory = @MainActor ( - UsageStore, - SettingsStore, - AccountInfo, - UpdaterProviding, - PreferencesSelection, - ManagedCodexAccountCoordinator, - CodexAccountPromotionCoordinator) + typealias Factory = + @MainActor ( + UsageStore, + SettingsStore, + AccountInfo, + UpdaterProviding, + PreferencesSelection, + ManagedCodexAccountCoordinator, + CodexAccountPromotionCoordinator) -> StatusItemControlling // swiftlint:disable:next function_parameter_count static func makeDefaultController( @@ -91,7 +92,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var isReleasedForTesting = false var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? - var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? + var _test_codexAmbientLoginRunnerOverride: + (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? #endif var blinkTask: Task? var loginTask: Task? { @@ -151,6 +153,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherUpdateToken = 0 var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] + var lastObservedStoreIconWorkSignature: String? + var iconPerfRefreshCycleMetrics: IconPerfRefreshCycleMetrics? + var iconPerfUpdatePassActive = false var lastKnownScreenCount: Int var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? @@ -234,7 +239,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return first } let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 - return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + return RateWindow( + usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) case .automatic, .primary: return first } @@ -246,7 +252,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin account: AccountInfo, updater: UpdaterProviding, preferencesSelection: PreferencesSelection, - managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), + managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = + ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, statusBar: NSStatusBar = .system, observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests) @@ -260,11 +267,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.updater = updater self.preferencesSelection = preferencesSelection self.managedCodexAccountCoordinator = managedCodexAccountCoordinator - self.codexAccountPromotionCoordinator = codexAccountPromotionCoordinator - ?? CodexAccountPromotionCoordinator( - settingsStore: settings, - usageStore: store, - managedAccountCoordinator: managedCodexAccountCoordinator) + self.codexAccountPromotionCoordinator = + codexAccountPromotionCoordinator + ?? CodexAccountPromotionCoordinator( + settingsStore: settings, + usageStore: store, + managedAccountCoordinator: managedCodexAccountCoordinator) self.lastConfigRevision = settings.configRevision self.lastProviderOrder = settings.providerOrder self.lastMergeIcons = settings.mergeIcons @@ -333,6 +341,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func wireBindings() { self.observeStoreChanges() self.observeStoreIconChanges() + self.observeIconPerfRefreshCycleChanges() self.observeDebugForceAnimation() self.observeSettingsChanges() self.observeUpdaterChanges() @@ -358,11 +367,59 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreIconChanges() + let signature = self.storeIconObservationSignature() + guard signature != self.lastObservedStoreIconWorkSignature else { return } + self.lastObservedStoreIconWorkSignature = signature self.updateIcons() } } } + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let needsAnimation = self.needsMenuBarIconAnimation() + let providerSignatures = UsageProvider.allCases.map { + self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) + }.joined(separator: "||") + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "iconStyle=\(String(describing: self.store.iconStyle))", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "needsAnimation=\(needsAnimation ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let stale = self.store.isStale(provider: provider) + let status = self.store.statusIndicator(for: provider).rawValue + let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) + let isAnimating = isVisibleForAnimation && !stale && snapshot == nil + let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) + let creditsRemaining = provider == .codex + ? self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + : nil + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + + return [ + provider.rawValue, + "style=\(String(describing: self.store.style(for: provider)))", + "snapshot=\(String(describing: snapshot))", + "stale=\(stale ? "1" : "0")", + "status=\(status)", + "anim=\(isAnimating ? "1" : "0")", + "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", + "credits=\(String(describing: creditsRemaining))", + "text=\(displayText ?? "nil")", + ].joined(separator: "|") + } + private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation @@ -542,6 +599,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif + self.lastObservedStoreIconWorkSignature = self.storeIconObservationSignature() + self.beginIconPerfUpdatePass() + defer { self.endIconPerfUpdatePass() } // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -726,7 +786,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } func isVisible(_ provider: UsageProvider) -> Bool { - self.store.debugForceAnimation || self.isEnabled(provider) || self.fallbackProvider == provider + self.store.debugForceAnimation || self.isEnabled(provider) + || self.fallbackProvider == provider } var shouldMergeIcons: Bool { @@ -734,7 +795,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } func switchAccountSubtitle(for target: UsageProvider) -> String? { - guard self.loginTask != nil, let provider = self.activeLoginProvider, provider == target else { return nil } + guard self.loginTask != nil, let provider = self.activeLoginProvider, provider == target + else { return nil } let base: String switch self.loginPhase { case .idle: return nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d798152d4..5c7cee212 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -43,7 +43,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.isRefreshing _ = self.refreshingProviders _ = self.statuses _ = self.historicalPaceRevision diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift new file mode 100644 index 000000000..6660c264e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -0,0 +1,89 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemIconObservationSignatureTests { + private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = false + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(Self.makeSnapshot(provider: .codex, email: "icon@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + return (settings, store, controller) + } + + @Test + func `store icon observation signature ignores refresh and status metadata churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-refresh-metadata") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.isRefreshing = true + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "same indicator, newer timestamp", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when status indicator changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-status-indicator") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.codex] = ProviderStatus( + indicator: .major, + description: "major outage", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} From c9428617d1d04c39411718f6d80c3c8e1cf54f3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 21 May 2026 23:54:15 +0100 Subject: [PATCH 2/2] docs: update changelog for icon observer perf --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a484875a0..2363fcb0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - OpenAI: parse Wednesday and Saturday dashboard reset lines so rate-limit reset times are not dropped on those days (#1080). Thanks @m1qaweb! - Localization: translate provider-detail labels and empty states when Simplified Chinese is selected (#1051). Thanks @wang93wei! - Antigravity: discover OAuth credentials from the bundled extension language server in newer IDE builds so Add Account works again (#1076). Thanks @xARSENICx! +- Menu bar: suppress redundant icon observer work during refresh cycles, reducing icon update passes without changing rendered state (#1081). Thanks @ptstory! - Menu bar: wait for display changes to settle before recovering status items and retry if macOS still leaves the icon detached (#1074). Thanks @yipjunkai! - Menu: keep lower action rows stable when Refresh is highlighted or pressed (#1071). Thanks @MadanChaollaPark! - Linux CLI: avoid linking JetBrains provider parsing against `libxml2.so.2`, improving compatibility with newer distros that ship libxml2 2.15+ (#1046). Thanks @semsemyonoff!