372 lines · 12.9 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport Observationimport QuartzCoreimport SwiftUI // MARK: - Status item controller (AppKit-hosted icons, SwiftUI popovers) @MainActorprotocol StatusItemControlling: AnyObject {    func openMenuFromShortcut()} @MainActorfinal class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling {    typealias Factory = (UsageStore, SettingsStore, AccountInfo, UpdaterProviding, PreferencesSelection)        -> StatusItemControlling    static let defaultFactory: Factory = { store, settings, account, updater, selection in        StatusItemController(            store: store,            settings: settings,            account: account,            updater: updater,            preferencesSelection: selection)    }     static var factory: Factory = StatusItemController.defaultFactory     let store: UsageStore    let settings: SettingsStore    let account: AccountInfo    let updater: UpdaterProviding    var statusItem: NSStatusItem    var statusItems: [UsageProvider: NSStatusItem] = [:]    var lastMenuProvider: UsageProvider?    var menuProviders: [ObjectIdentifier: UsageProvider] = [:]    var menuContentVersion: Int = 0    var menuVersions: [ObjectIdentifier: Int] = [:]    var mergedMenu: NSMenu?    var providerMenus: [UsageProvider: NSMenu] = [:]    var fallbackMenu: NSMenu?    var openMenus: [ObjectIdentifier: NSMenu] = [:]    var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]    var blinkTask: 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 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    let loginLogger = CodexBarLog.logger("login")    var selectedMenuProvider: UsageProvider? {        get { self.settings.selectedMenuProvider }        set { self.settings.selectedMenuProvider = newValue }    }     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?) -> RateWindow? {        switch self.settings.menuBarMetricPreference(for: provider) {        case .primary:            return snapshot?.primary ?? snapshot?.secondary        case .secondary:            return snapshot?.secondary ?? snapshot?.primary        case .average:            guard let primary = snapshot?.primary, let secondary = snapshot?.secondary else {                return snapshot?.primary ?? snapshot?.secondary            }            let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2            return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil)        case .automatic:            if provider == .factory {                return snapshot?.secondary ?? snapshot?.primary            }            return snapshot?.primary ?? snapshot?.secondary        }    }     init(        store: UsageStore,        settings: SettingsStore,        account: AccountInfo,        updater: UpdaterProviding,        preferencesSelection: PreferencesSelection)    {        self.store = store        self.settings = settings        self.account = account        self.updater = updater        self.preferencesSelection = preferencesSelection        let bar = NSStatusBar.system        let item = bar.statusItem(withLength: NSStatusItem.variableLength)        // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images).        item.button?.imageScaling = .scaleNone        self.statusItem = item        // Status items for individual providers are now created lazily in updateVisibility()        super.init()        self.wireBindings()        self.updateIcons()        self.updateVisibility()        NotificationCenter.default.addObserver(            self,            selector: #selector(self.handleDebugReplayNotification(_:)),            name: .codexbarDebugReplayAllAnimations,            object: nil)        NotificationCenter.default.addObserver(            self,            selector: #selector(self.handleDebugBlinkNotification),            name: .codexbarDebugBlinkNow,            object: nil)    }     private func wireBindings() {        self.observeStoreChanges()        self.observeDebugForceAnimation()        self.observeSettingsChanges()        self.observeUpdaterChanges()    }     private func observeStoreChanges() {        withObservationTracking {            _ = self.store.menuObservationToken        } onChange: { [weak self] in            Task { @MainActor [weak self] in                guard let self else { return }                self.observeStoreChanges()                self.invalidateMenus()                self.updateIcons()                self.updateBlinkingState()            }        }    }     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.invalidateMenus()                self.updateVisibility()                self.updateIcons()            }        }    }     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 invalidateMenus() {        self.menuContentVersion &+= 1        // Don't refresh menus while they're open - wait until they close and reopen        // This prevents expensive rebuilds while user is navigating the menu        guard self.openMenus.isEmpty else { return }        self.refreshOpenMenusIfNeeded()        Task { @MainActor in            // AppKit can ignore menu mutations while tracking; retry on the next run loop.            await Task.yield()            guard self.openMenus.isEmpty else { return }            self.refreshOpenMenusIfNeeded()        }    }     private func updateIcons() {        // 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 {            self.applyIcon(phase: phase)            self.attachMenus()        } else {            UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) }            self.attachMenus(fallback: self.fallbackProvider)        }        self.updateAnimationState()        self.updateBlinkingState()    }     /// 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 bar = NSStatusBar.system        let item = bar.statusItem(withLength: NSStatusItem.variableLength)        item.button?.imageScaling = .scaleNone        self.statusItems[provider] = item        return item    }     private func updateVisibility() {        let anyEnabled = !self.store.enabledProviders().isEmpty        let force = self.store.debugForceAnimation        if self.shouldMergeIcons {            self.statusItem.isVisible = anyEnabled || force            for item in self.statusItems.values {                item.isVisible = false            }            self.attachMenus()        } else {            self.statusItem.isVisible = false            let fallback = self.fallbackProvider            for provider in UsageProvider.allCases {                let isEnabled = self.isEnabled(provider)                let shouldBeVisible = isEnabled || fallback == provider || force                if shouldBeVisible {                    let item = self.lazyStatusItem(for: provider)                    item.isVisible = true                } else if let item = self.statusItems[provider] {                    item.isVisible = false                }            }            self.attachMenus(fallback: fallback)        }        self.updateAnimationState()        self.updateBlinkingState()    }     var fallbackProvider: UsageProvider? {        self.store.enabledProviders().isEmpty ? .codex : nil    }     func isEnabled(_ provider: UsageProvider) -> Bool {        self.store.isEnabled(provider)    }     private func refreshMenusForLoginStateChange() {        self.invalidateMenus()        if self.shouldMergeIcons {            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        }    }     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 exists but is no longer needed - clear its menu                if item.menu != nil {                    item.menu = nil                }            }        }    }     func isVisible(_ provider: UsageProvider) -> Bool {        self.store.debugForceAnimation || self.isEnabled(provider) || self.fallbackProvider == provider    }     var shouldMergeIcons: Bool {        self.settings.mergeIcons && self.store.enabledProviders().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 = "Requesting login…"        case .waitingBrowser: base = "Waiting in browser…"        }        let prefix = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName        return "\(prefix): \(base)"    }     deinit {        self.blinkTask?.cancel()        self.loginTask?.cancel()        NotificationCenter.default.removeObserver(self)    }}