Files
steipete--codexbar/Sources/CodexBar/StatusItemController.swift
T
2026-07-13 12:22:33 +08:00

1031 lines
43 KiB
Swift

import AppKit
import CodexBarCore
import Observation
import QuartzCore
// MARK: - Status item controller (AppKit-hosted icons, SwiftUI popovers)
@MainActor
protocol StatusItemControlling: AnyObject {
func openMenuFromShortcut()
func runLoginFlowFromSettings(provider: UsageProvider) async
func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint?
func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary
#if DEBUG
func seedRebuildableCachesForMemoryPressureProof()
#endif
func prepareForAppShutdown()
}
extension StatusItemControlling {
func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? {
nil
}
func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary {
MemoryPressureCacheTrimSummary()
}
#if DEBUG
func seedRebuildableCachesForMemoryPressureProof() {}
#endif
func prepareForAppShutdown() {}
}
struct NativeHighlightDeferredMenuRebuild {
let provider: UsageProvider?
}
@MainActor
final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling {
// Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes.
static var menuCardRenderingEnabled = !SettingsStore.isRunningTests
private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests
private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests
static let quotaWarningFlashDuration: TimeInterval = 60
private nonisolated static let statusItemAccessibilityTitle = "CodexBar"
private nonisolated static let debugStatusItemAccessibilityTitle = "CodexBar Debug"
private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem"
private nonisolated static let mergedLegacyDefaultItemIndex = 0
enum StatusItemIdentity {
case merged
case provider(UsageProvider)
var autosaveName: String {
switch self {
case .merged:
"codexbar-merged"
case let .provider(provider):
"codexbar-\(provider.rawValue)"
}
}
var accessibilityIdentifier: String {
switch self {
case .merged:
StatusItemController.statusItemAccessibilityIdentifierPrefix
case let .provider(provider):
"\(StatusItemController.statusItemAccessibilityIdentifierPrefix).\(provider.rawValue)"
}
}
}
nonisolated static func isDebugApp(bundleIdentifier: String?) -> Bool {
bundleIdentifier?.contains(".debug") == true
}
nonisolated static func statusItemAccessibilityTitle(isDebugApp: Bool) -> String {
isDebugApp ? self.debugStatusItemAccessibilityTitle : self.statusItemAccessibilityTitle
}
#if DEBUG
var menuRefreshEnabledOverrideForTesting: Bool?
#endif
typealias Factory =
@MainActor (
UsageStore,
SettingsStore,
AccountInfo,
UpdaterProviding,
PreferencesSelection,
ManagedCodexAccountCoordinator,
CodexAccountPromotionCoordinator)
-> StatusItemControlling
// swiftlint:disable:next function_parameter_count
static func makeDefaultController(
store: UsageStore,
settings: SettingsStore,
account: AccountInfo,
updater: UpdaterProviding,
selection: PreferencesSelection,
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator,
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator)
-> StatusItemControlling
{
StatusItemController(
store: store,
settings: settings,
account: account,
updater: updater,
preferencesSelection: selection,
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)
}
static let defaultFactory: Factory = StatusItemController.makeDefaultController
static var factory: Factory = StatusItemController.defaultFactory
let store: UsageStore
let settings: SettingsStore
let agentSessions: AgentSessionsStore
lazy var menuCardRefreshMonitor = self.makeMenuCardRefreshMonitor()
let account: AccountInfo
let updater: UpdaterProviding
let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
let statusBar: NSStatusBar
let menuCardRenderingEnabledForController: Bool
let menuRefreshEnabledForController: Bool
var statusItem: NSStatusItem
var statusItems: [UsageProvider: NSStatusItem] = [:]
/// App intent survives Tahoe changing `NSStatusItem.isVisible` after Control Center rejects its scene.
var expectedVisibleStatusItemAutosaveNames: Set<String> = []
var lastMenuProvider: UsageProvider?
var menuProviders: [ObjectIdentifier: UsageProvider] = [:]
var menuSession = MenuSessionCoordinator<ObjectIdentifier>()
var menuReadinessSignatures: [ObjectIdentifier: String] = [:]
let hostedSubviewRenderSignatures = NSMapTable<NSMenu, HostedSubviewRenderSignatureBox>.weakToStrongObjects()
/// Persistent Refresh rows are weakly tracked so their enabled state can change during menu tracking.
let persistentRefreshItems = NSHashTable<NSMenuItem>.weakObjects()
var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:]
var measuredStandardMenuWidthCache: [String: CGFloat] = [:]
var lastMenuAdjunctReadinessSignature = ""
var lastMenuAdjunctReadinessBaselineVersion = 0
var rootOpenHandledMenuObservationSignature: String?
var mergedMenu: NSMenu?
var providerMenus: [UsageProvider: NSMenu] = [:]
var fallbackMenu: NSMenu?
var openMenus: [ObjectIdentifier: NSMenu] = [:]
var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
/// Manual refreshes tracked per scope so refreshing one provider neither greys out nor blocks
/// a manual refresh of another. `.global` covers the all-providers refresh (⌘R / merged overview).
var manualRefreshTasks: [ManualRefreshScope: Task<Void, Never>] = [:]
var closedMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var closedMenuRebuildRequests = MenuRebuildRequestRegistry<ObjectIdentifier>()
var openMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var openMenuRebuildRequests = MenuRebuildRequestRegistry<ObjectIdentifier>()
var menuIdentitySignatures: [ObjectIdentifier: String] = [:]
var codexAccountMenuProjectionRevalidationTask: Task<Void, Never>?
var openMenuRebuildsClosingHostedSubviewMenus: Set<ObjectIdentifier> = []
var parentMenuRebuildPendingAfterHostedSubviewClose = false
var deferredMenuInteractionRefreshProviders: Set<UsageProvider> = []
var deferredMenuInteractionRefreshPending: Bool {
!self.deferredMenuInteractionRefreshProviders.isEmpty
}
var deferredOpenAIDashboardRefreshReason: String?
var deferredMenuInteractionRefreshTask: Task<Void, Never>?
var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:]
/// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry.
var nativeHighlightDeferredMenuRebuilds: [ObjectIdentifier: NativeHighlightDeferredMenuRebuild] = [:]
/// Baseline resync intent survives rebuild coalescing and any native-row or hosted-submenu deferral.
var pendingMenuBaselineResyncs: Set<ObjectIdentifier> = []
var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor?
var providerSwitcherShortcutMenuID: ObjectIdentifier?
var providerSwitcherPointerInteractionMenuID: ObjectIdentifier?
var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild?
var overviewScrollAccumulatedDelta: CGFloat = 0
var overviewScrollNavigationHandlerForTesting: ((OverviewScrollStep) -> Void)?
var hasPreparedForAppShutdown = false
var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in
DispatchQueue.main.async {
Task { @MainActor in
operation()
}
}
}
var terminateApplicationForQuit: @MainActor () -> Void = {
NSApp.terminate(nil)
}
var openMenuInvalidationRetryTask: Task<Void, Never>?
#if DEBUG
var onDelayedMenuRefreshAttemptForTesting: (() -> Void)?
var onDeferredMenuInteractionRefreshForTesting: (() -> Void)?
var onOpenMenuInvalidationRetryForTesting: (() -> Void)?
var isReleasedForTesting = false
var lastLoggedClosedMenuRebuildVersion: Int?
var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)?
var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)?
var _test_providerSwitcherMenuRebuildDebounceNanoseconds: UInt64?
var _test_codexAmbientLoginRunnerOverride:
(@MainActor (TimeInterval) async -> CodexLoginRunner.Result)?
#endif
var manualRefreshViewportRestoreState = ManualRefreshViewportRestoreState()
var blinkTask: Task<Void, Never>?
var menuBarCountdownRefreshTask: Task<Void, Never>?
var loginTask: Task<Void, Never>? {
didSet { self.refreshMenusForLoginStateChange() }
}
var creditsPurchaseWindow: OpenAICreditsPurchaseWindowController?
var activeLoginProvider: UsageProvider? {
didSet {
if oldValue != self.activeLoginProvider {
self.refreshMenusForLoginStateChange()
}
}
}
var blinkStates: [UsageProvider: BlinkState] = [:]
var blinkAmounts: [UsageProvider: CGFloat] = [:]
var wiggleAmounts: [UsageProvider: CGFloat] = [:]
var tiltAmounts: [UsageProvider: CGFloat] = [:]
var quotaWarningFlashUntil: [UsageProvider: Date] = [:]
var quotaWarningFlashTasks: [UsageProvider: Task<Void, Never>] = [:]
var blinkForceUntil: Date?
var loginPhase: LoginPhase = .idle {
didSet {
if oldValue != self.loginPhase {
self.refreshMenusForLoginStateChange()
}
}
}
let preferencesSelection: PreferencesSelection
var animationDriver: DisplayLinkDriver?
var animationPhase: Double = 0
var animationPattern: LoadingPattern = .knightRider
var animationStartedAt: Date?
private var lastConfigRevision: Int
private var lastProviderOrder: [UsageProvider]
private var lastMergeIcons: Bool
private var lastSwitcherShowsIcons: Bool
private var lastObservedUsageBarsShowUsed: Bool
var lastWidgetDisplaySettingsSignature = ""
private var lastAgentSessionsEnabled: Bool
private var lastAgentSessionsManualHosts: String
/// Tracks which `usageBarsShowUsed` mode the provider switcher was built with.
/// Used to decide whether we can "smart update" menu content without rebuilding the switcher.
var lastSwitcherUsageBarsShowUsed: Bool
/// Tracks whether the merged-menu switcher was built with the Overview tab visible.
/// Used to force switcher rebuilds when Overview availability toggles.
var lastSwitcherIncludesOverview: Bool = false
/// Tracks localization-sensitive labels used by the merged menu.
/// Used to force menu rebuilds when app language changes.
var lastMenuLocalizationSignature: String = ""
/// Tracks which providers the merged menu's switcher was built with, to detect when it needs full rebuild.
var lastSwitcherProviders: [UsageProvider] = []
/// Tracks which switcher tab state was used for the current merged-menu switcher instance.
var lastMergedSwitcherSelection: ProviderSwitcherSelection?
/// Tracks which provider/overview content is currently attached below the merged-menu switcher.
var lastMergedMenuContentSelection: ProviderSwitcherSelection?
/// Tracks the visible Codex account switcher contents for merged-menu smart updates.
var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay?
/// Tracks the visible token account switcher contents for merged-menu smart updates.
var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay?
/// Keeps detached merged-menu tab content reusable while the same menu remains open.
var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]]
= [:]
var preservesMergedSwitcherContentCachesDuringInvalidation = false
/// Card hosting views harvested from items about to be discarded by the current populate
/// pass, keyed by card identifier; consumed by `makeMenuCardItem` and cleared when the
/// pass finishes. Never outlives a single synchronous menu population.
var menuCardViewRecyclePool: [String: NSView] = [:]
/// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds.
var providerSwitcherUpdateToken = 0
var providerSelectionUIRefreshTask: Task<Void, Never>?
var deferredMergedIconRenderAfterTracking = false
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<Void, Never>?
let loginLogger = CodexBarLog.logger(LogCategories.login)
let menuLogger = CodexBarLog.logger(LogCategories.app)
var selectedMenuProvider: UsageProvider? {
get { self.settings.selectedMenuProvider }
set { self.settings.selectedMenuProvider = newValue }
}
private static func makeStatusItem(
statusBar: NSStatusBar,
identity: StatusItemIdentity,
defaults: UserDefaults,
legacyDefaultItemIndex: Int?)
-> NSStatusItem
{
MenuBarStatusItemPlacementPreflight.prepare(
defaults: defaults,
autosaveName: identity.autosaveName,
legacyDefaultItemIndex: legacyDefaultItemIndex)
let item = statusBar.statusItem(withLength: NSStatusItem.variableLength)
item.autosaveName = identity.autosaveName
if let button = item.button {
let title = self.statusItemAccessibilityTitle(
isDebugApp: self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier))
// Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images).
button.imageScaling = .scaleNone
button.setAccessibilityIdentifier(identity.accessibilityIdentifier)
button.setAccessibilityTitle(title)
button.toolTip = title
}
return item
}
struct BlinkState {
var nextBlink: Date
var blinkStart: Date?
var pendingSecondStart: Date?
var effect: MotionEffect = .blink
static func randomDelay() -> TimeInterval {
Double.random(in: 3...12)
}
}
enum MotionEffect {
case blink
case wiggle
case tilt
}
enum LoginPhase {
case idle
case requesting
case waitingBrowser
}
func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? {
if provider == .codex {
return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now)
}
return MenuBarMetricWindowResolver.rateWindow(
preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot),
provider: provider,
snapshot: snapshot,
supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider))
}
private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?, now: Date) -> RateWindow? {
guard let snapshot else { return nil }
return self.store.codexMenuBarMetricWindow(snapshot: snapshot, now: now)
}
init(
store: UsageStore,
settings: SettingsStore,
account: AccountInfo,
updater: UpdaterProviding,
preferencesSelection: PreferencesSelection,
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator =
ManagedCodexAccountCoordinator(),
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil,
statusBar: NSStatusBar = .system,
menuCardRenderingEnabled: Bool = StatusItemController.menuCardRenderingEnabled,
menuRefreshEnabled: Bool = StatusItemController.menuRefreshEnabled,
observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests)
{
if SettingsStore.isRunningTests {
_ = NSApplication.shared
}
self.store = store
self.settings = settings
self.agentSessions = AgentSessionsStore(settings: settings)
self.account = account
self.updater = updater
self.preferencesSelection = preferencesSelection
self.managedCodexAccountCoordinator = managedCodexAccountCoordinator
self.codexAccountPromotionCoordinator =
codexAccountPromotionCoordinator
?? CodexAccountPromotionCoordinator(
settingsStore: settings,
usageStore: store,
managedAccountCoordinator: managedCodexAccountCoordinator)
self.lastConfigRevision = settings.configRevision
self.lastProviderOrder = settings.providerOrder
self.lastMergeIcons = settings.mergeIcons
self.lastSwitcherShowsIcons = settings.switcherShowsIcons
self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed
self.lastAgentSessionsEnabled = settings.agentSessionsEnabled
self.lastAgentSessionsManualHosts = settings.agentSessionsManualHosts
self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed
self.menuCardRenderingEnabledForController = menuCardRenderingEnabled
self.menuRefreshEnabledForController = menuRefreshEnabled
let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair
.repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults)
self.statusBar = statusBar
self.statusItem = Self.makeStatusItem(
statusBar: statusBar,
identity: .merged,
defaults: settings.userDefaults,
legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex)
self.lastKnownScreenCount = NSScreen.screens.count
// Status items for individual providers are now created lazily in updateVisibility()
super.init()
if !repairedStatusItemVisibilityKeys.isEmpty {
self.menuLogger.info(
"Repaired hidden macOS status-item visibility defaults",
metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")])
}
self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature()
self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion
self.lastWidgetDisplaySettingsSignature = self.widgetDisplaySettingsSignature()
self.wireBindings()
self.agentSessions.onUpdate = { [weak self] in
self?.invalidateMenus(refreshOpenMenus: true)
}
if !SettingsStore.isRunningTests {
self.agentSessions.start()
}
self.updateVisibility()
self.updateIcons()
self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded(
for: self.store.enabledProvidersForDisplay())
self.scheduleStartupStatusItemVisibilityCheck()
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleDebugReplayNotification(_:)),
name: .codexbarDebugReplayAllAnimations,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleDebugBlinkNotification),
name: .codexbarDebugBlinkNow,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleQuotaWarningPosted(_:)),
name: .codexbarQuotaWarningDidPost,
object: nil)
if observeProviderConfigNotifications {
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleProviderConfigDidChange),
name: .codexbarProviderConfigDidChange,
object: nil)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleScreenParametersDidChange(_:)),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
self.observeMenuBarTimeEnvironmentChanges()
}
convenience init(
store: UsageStore,
settings: SettingsStore,
account: AccountInfo,
updater: UpdaterProviding,
preferencesSelection: PreferencesSelection,
statusBar: NSStatusBar = .system,
menuCardRenderingEnabled: Bool = StatusItemController.menuCardRenderingEnabled,
menuRefreshEnabled: Bool = StatusItemController.menuRefreshEnabled,
observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests)
{
self.init(
store: store,
settings: settings,
account: account,
updater: updater,
preferencesSelection: preferencesSelection,
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator(),
codexAccountPromotionCoordinator: nil,
statusBar: statusBar,
menuCardRenderingEnabled: menuCardRenderingEnabled,
menuRefreshEnabled: menuRefreshEnabled,
observeProviderConfigNotifications: observeProviderConfigNotifications)
}
private func wireBindings() {
self.observeStoreChanges()
self.observeStoreIconChanges()
self.observeIconPerfRefreshCycleChanges()
self.observeDebugForceAnimation()
self.observeSettingsChanges()
self.observeUpdaterChanges()
self.observeManagedCodexCoordinatorChanges()
}
private func observeStoreChanges() {
withObservationTracking {
_ = self.store.menuObservationToken
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.handleObservedStoreMenuChange()
}
}
}
func handleObservedStoreMenuChange() {
self.observeStoreChanges()
self.updatePersistentRefreshItemsEnabled()
let rootOpenHandledReadiness = self.consumeRootOpenHandledMenuObservationIfNeeded()
// `refreshOpenMenus` is only consulted when a menu is currently open.
// Computing the readiness signature serializes every enabled provider's
// token snapshot and 30-day daily breakdown, which is wasted main-thread
// work on the common path where no menu is open (background refresh ticks).
let refreshOpenMenus = self.openMenus.isEmpty
? false
: rootOpenHandledReadiness || self.didMenuAdjunctReadinessChange()
self.invalidateMenus(
refreshOpenMenus: refreshOpenMenus,
deferOpenParentMenuRebuild: true,
allowStaleContentDuringDataRefresh: true)
self.completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded()
}
private func observeStoreIconChanges() {
withObservationTracking {
_ = self.store.iconObservationToken
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeStoreIconChanges()
let signature = self.storeIconObservationSignature()
guard signature != self.lastObservedStoreIconWorkSignature else { return }
// Reuse the signature we just computed for the change check; `updateIcons` would
// otherwise recompute the identical value on the same main-actor turn.
self.updateIcons(precomputedStoreIconSignature: signature)
}
}
}
private func observeDebugForceAnimation() {
withObservationTracking {
_ = self.store.debugForceAnimation
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeDebugForceAnimation()
self.updateVisibility()
self.updateBlinkingState()
}
}
}
private func observeSettingsChanges() {
withObservationTracking {
_ = self.settings.menuObservationToken
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeSettingsChanges()
self.handleSettingsChange(reason: "observation")
}
}
}
func handleProviderConfigChange(reason: String) {
self.handleSettingsChange(reason: "config:\(reason)")
}
@objc private func handleProviderConfigDidChange(_ notification: Notification) {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
let reason = notification.userInfo?["reason"] as? String ?? "unknown"
let affectsBackgroundWork = notification.userInfo?["affectsBackgroundWork"] as? Bool
if let source = notification.object as? SettingsStore,
source !== self.settings
{
if let config = notification.userInfo?["config"] as? CodexBarConfig {
self.settings.applyExternalConfig(
config,
reason: "external-\(reason)",
affectsBackgroundWork: affectsBackgroundWork)
} else {
self.settings.reloadConfig(
reason: "external-\(reason)",
affectsBackgroundWork: affectsBackgroundWork)
}
}
self.handleProviderConfigChange(reason: "notification:\(reason)")
}
@objc private func handleQuotaWarningPosted(_ notification: Notification) {
guard let event = notification.object as? QuotaWarningPostedEvent else { return }
self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt)
}
private func observeUpdaterChanges() {
withObservationTracking {
_ = self.updater.updateStatus.isUpdateReady
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeUpdaterChanges()
self.invalidateMenus()
}
}
}
private func observeManagedCodexCoordinatorChanges() {
withObservationTracking {
_ = self.managedCodexAccountCoordinator.isAuthenticatingManagedAccount
_ = self.managedCodexAccountCoordinator.authenticatingManagedAccountID
_ = self.managedCodexAccountCoordinator.isRemovingManagedAccount
_ = self.managedCodexAccountCoordinator.removingManagedAccountID
_ = self.codexAccountPromotionCoordinator.isAuthenticatingLiveAccount
_ = self.codexAccountPromotionCoordinator.isPromotingSystemAccount
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.observeManagedCodexCoordinatorChanges()
self.refreshMenusForLoginStateChange()
}
}
}
private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool {
var shouldRefresh = false
let revision = self.settings.configRevision
if revision != self.lastConfigRevision {
self.lastConfigRevision = revision
shouldRefresh = true
}
let order = self.settings.providerOrder
if order != self.lastProviderOrder {
self.lastProviderOrder = order
shouldRefresh = true
}
let mergeIcons = self.settings.mergeIcons
if mergeIcons != self.lastMergeIcons {
self.lastMergeIcons = mergeIcons
shouldRefresh = true
}
let showsIcons = self.settings.switcherShowsIcons
if showsIcons != self.lastSwitcherShowsIcons {
self.lastSwitcherShowsIcons = showsIcons
shouldRefresh = true
}
let usageBarsShowUsed = self.settings.usageBarsShowUsed
if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed {
self.lastObservedUsageBarsShowUsed = usageBarsShowUsed
shouldRefresh = true
}
if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature {
shouldRefresh = true
}
return shouldRefresh
}
private func handleSettingsChange(reason: String) {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
let agentSessionsSettingsChanged = self.settings.agentSessionsEnabled != self.lastAgentSessionsEnabled ||
self.settings.agentSessionsManualHosts != self.lastAgentSessionsManualHosts
if agentSessionsSettingsChanged {
self.lastAgentSessionsEnabled = self.settings.agentSessionsEnabled
self.lastAgentSessionsManualHosts = self.settings.agentSessionsManualHosts
self.agentSessions.settingsDidChange()
}
let configChanged = self.settings.configRevision != self.lastConfigRevision
let orderChanged = self.settings.providerOrder != self.lastProviderOrder
let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature
let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher()
self.invalidateMenus()
if orderChanged || configChanged {
self.rebuildProviderStatusItems()
}
self.updateVisibility()
self.updateIcons()
self.persistWidgetSnapshotIfWidgetDisplaySettingsChanged()
if shouldRefreshOpenMenus {
self.refreshOpenMenusAllowingParentRebuild(
deferParentRebuildDuringTracking: !localizationChanged)
}
}
/// Updates the menu bar icons.
///
/// The store-icon observer already computes `storeIconObservationSignature()` to decide whether any
/// icon work is needed, so it passes that value in via `precomputedStoreIconSignature` to avoid
/// recomputing the identical signature on the same main-actor turn. Other callers omit it and let the
/// signature refresh here, keeping `lastObservedStoreIconWorkSignature` current as the change gate.
func updateIcons(precomputedStoreIconSignature: String? = nil) {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
MainThreadActivityBreadcrumb.push("updateIcons")
self.scheduleMenuBarCountdownRefreshIfNeeded()
self.lastObservedStoreIconWorkSignature = precomputedStoreIconSignature ?? self.storeIconObservationSignature()
self.beginIconPerfUpdatePass()
defer {
self.endIconPerfUpdatePass()
MainThreadActivityBreadcrumb.pop()
}
// 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
if self.shouldMergeIcons {
let skippedMergedRender = self.applyIcon(phase: phase)
if skippedMergedRender,
!self.deferredMergedIconRenderAfterTracking,
self.mergedMenu != nil
{
return
}
guard !self.isMergedMenuOpen else {
self.updateAnimationState()
self.updateBlinkingState()
return
}
self.attachMenus()
} else {
UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) }
self.attachMenus(fallback: self.fallbackProvider)
}
self.updateAnimationState()
self.updateBlinkingState()
}
var isMergedMenuOpen: Bool {
guard let mergedMenu else { return false }
return self.openMenus[ObjectIdentifier(mergedMenu)] != nil
}
/// Lazily retrieves or creates a status item for the given provider
func lazyStatusItem(for provider: UsageProvider) -> NSStatusItem {
if let existing = self.statusItems[provider] {
return existing
}
let item = Self.makeStatusItem(
statusBar: self.statusBar,
identity: .provider(provider),
defaults: self.settings.userDefaults,
legacyDefaultItemIndex: self.legacyDefaultItemIndex(forNewProvider: provider))
self.statusItems[provider] = item
return item
}
func recreateStatusItemsForVisibilityRecovery() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
self.statusItem.menu = nil
self.statusBar.removeStatusItem(self.statusItem)
self.statusItem = Self.makeStatusItem(
statusBar: self.statusBar,
identity: .merged,
defaults: self.settings.userDefaults,
legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex)
for provider in Array(self.statusItems.keys) {
self.removeProviderStatusItem(for: provider)
}
self.lastAppliedMergedIconRenderSignature = nil
self.lastAppliedProviderIconRenderSignatures.removeAll()
self.updateVisibility()
self.updateIcons()
}
private func updateVisibility() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty
let force = self.store.debugForceAnimation
let mergeIcons = self.shouldMergeIcons
var expectedVisibleAutosaveNames: Set<String> = []
if mergeIcons {
let shouldBeVisible = anyEnabled || force
self.statusItem.isVisible = shouldBeVisible
if shouldBeVisible {
expectedVisibleAutosaveNames.insert(self.statusItem.autosaveName)
}
for provider in Array(self.statusItems.keys) {
self.removeProviderStatusItem(for: provider)
}
self.attachMenus()
} else {
self.statusItem.isVisible = false
let fallback = self.fallbackProvider
for provider in self.settings.orderedProviders() {
let isEnabled = self.isEnabled(provider)
let shouldBeVisible = isEnabled || fallback == provider || force
if shouldBeVisible {
let item = self.lazyStatusItem(for: provider)
item.isVisible = true
expectedVisibleAutosaveNames.insert(item.autosaveName)
} else {
self.removeProviderStatusItem(for: provider)
}
}
self.attachMenus(fallback: fallback)
}
self.expectedVisibleStatusItemAutosaveNames = expectedVisibleAutosaveNames
self.updateAnimationState()
self.updateBlinkingState()
}
func isEnabled(_ provider: UsageProvider) -> Bool {
self.store.isEnabled(provider)
}
private func refreshMenusForLoginStateChange() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
self.invalidateMenus()
if self.shouldMergeIcons {
guard !self.isMergedMenuOpen else { return }
self.attachMenus()
} else {
self.attachMenus(fallback: self.fallbackProvider)
}
}
private func attachMenus() {
if self.mergedMenu == nil {
self.mergedMenu = self.makeMenu()
}
if self.statusItem.menu !== self.mergedMenu {
self.statusItem.menu = self.mergedMenu
}
self.prepareAttachedClosedMenusIfNeeded()
}
private func attachMenus(fallback: UsageProvider? = nil) {
for provider in UsageProvider.allCases {
// Only access/create the status item if it's actually needed
let shouldHaveItem = self.isEnabled(provider) || fallback == provider
if shouldHaveItem {
let item = self.lazyStatusItem(for: provider)
if self.isEnabled(provider) {
if self.providerMenus[provider] == nil {
self.providerMenus[provider] = self.makeMenu(for: provider)
}
let menu = self.providerMenus[provider]
if item.menu !== menu {
item.menu = menu
}
} else if fallback == provider {
if self.fallbackMenu == nil {
self.fallbackMenu = self.makeMenu(for: nil)
}
if item.menu !== self.fallbackMenu {
item.menu = self.fallbackMenu
}
}
} else if let item = self.statusItems[provider] {
item.menu = nil
}
}
self.prepareAttachedClosedMenusIfNeeded()
}
private func rebuildProviderStatusItems() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
let ordered = self.settings.orderedProviders()
let desired = Set(ordered)
for provider in Array(self.statusItems.keys) where !desired.contains(provider) {
self.removeProviderStatusItem(for: provider)
}
guard !self.shouldMergeIcons else { return }
let fallback = self.fallbackProvider
let force = self.store.debugForceAnimation
for provider in ordered where self.isEnabled(provider) || fallback == provider || force {
_ = self.lazyStatusItem(for: provider)
}
}
private func removeProviderStatusItem(for provider: UsageProvider) {
if let menu = self.providerMenus.removeValue(forKey: provider) {
let menuID = ObjectIdentifier(menu)
if menuID == self.providerSwitcherShortcutMenuID {
self.removeProviderSwitcherShortcutMonitor()
}
self.clearMergedSwitcherContentCache(for: menu)
self.removeMenuLifecycleState(menuID)
}
guard let item = self.statusItems.removeValue(forKey: provider) else { return }
item.menu = nil
self.lastAppliedProviderIconRenderSignatures.removeValue(forKey: provider)
self.statusBar.removeStatusItem(item)
}
func isVisible(_ provider: UsageProvider) -> Bool {
self.store.debugForceAnimation || self.isEnabled(provider)
|| self.fallbackProvider == provider
}
var shouldMergeIcons: Bool {
self.settings.mergeIcons && self.store.enabledProvidersForDisplay().count > 1
}
func switchAccountSubtitle(for target: UsageProvider) -> String? {
guard self.loginTask != nil, let provider = self.activeLoginProvider, provider == target
else { return nil }
let base: String
switch self.loginPhase {
case .idle: return nil
case .requesting: base = L("Requesting login…")
case .waitingBrowser: base = L("Waiting in browser…")
}
let prefix = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
return "\(prefix): \(base)"
}
deinit {
let animationDriver = self.animationDriver
Task { @MainActor in
animationDriver?.stop()
}
self.blinkTask?.cancel()
self.menuBarCountdownRefreshTask?.cancel()
self.loginTask?.cancel()
self.screenChangeVisibilityTask?.cancel()
self.pendingScreenChangePreviousCount = nil
NotificationCenter.default.removeObserver(self)
}
}
#if DEBUG
extension StatusItemController {
var _test_manualRefreshOperation: (@MainActor () async -> Void)? {
get { self.manualRefreshViewportRestoreState.testOperation }
set { self.manualRefreshViewportRestoreState.testOperation = newValue }
}
var _test_menuViewportRestoreObserver: (@MainActor (NSMenu) -> Void)? {
get { self.manualRefreshViewportRestoreState.testObserver }
set { self.manualRefreshViewportRestoreState.testObserver = newValue }
}
var _test_menuViewportRestoreScheduler: ((@escaping @MainActor () -> Void) -> Void)? {
get { self.manualRefreshViewportRestoreState.testScheduler }
set { self.manualRefreshViewportRestoreState.testScheduler = newValue }
}
var menuContentVersion: Int {
get { self.menuSession.contentVersion }
set { self.menuSession.replaceContentVersionForTesting(newValue) }
}
var latestRequiredMenuRebuildVersion: Int {
self.menuSession.latestRequiredRebuildVersion
}
var latestDataOnlyMenuContentVersion: Int {
self.menuSession.latestDataOnlyContentVersion
}
var latestStructuralMenuContentVersion: Int {
self.menuSession.latestStructuralContentVersion
}
var menuVersions: [ObjectIdentifier: Int] {
get { self.menuSession.renderedVersions }
set { self.menuSession.replaceRenderedVersionsForTesting(newValue) }
}
var closedMenusDeferredUntilNextOpen: Set<ObjectIdentifier> {
get { self.menuSession.deferredUntilNextOpen }
set { self.menuSession.replaceDeferredMenusForTesting(newValue) }
}
var parentMenuRebuildsDeferredDuringTracking: Set<ObjectIdentifier> {
self.menuSession.parentRebuildsDeferredDuringTracking
}
var closedMenuRebuildTokens: [ObjectIdentifier: Int] {
self.closedMenuRebuildRequests.tokens
}
}
#endif
#if DEBUG
extension StatusItemController {
static func setMenuRefreshEnabledForTesting(_ enabled: Bool) {
self.menuRefreshEnabled = enabled
}
static func resetMenuRefreshEnabledForTesting() {
self.menuRefreshEnabled = self.defaultMenuRefreshEnabled
}
}
#endif
extension StatusItemController {
private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? {
let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) }
guard let providerOffset = visibleProviders.firstIndex(of: provider) else { return nil }
return Self.mergedLegacyDefaultItemIndex + 1 + providerOffset
}
func refreshExistingStatusItemsForVisibilityRecovery() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
let visibleItems = ([self.statusItem] + Array(self.statusItems.values)).filter(\.isVisible)
for item in visibleItems {
item.isVisible = false
}
for item in visibleItems {
item.isVisible = true
}
self.updateVisibility()
self.updateIcons()
}
}