222 lines · 7.0 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport Combineimport OSLogimport QuartzCoreimport SwiftUI // MARK: - Status item controller (AppKit-hosted icons, SwiftUI popovers) protocol StatusItemControlling: AnyObject {} @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 statusItems: [UsageProvider: NSStatusItem] = [:]    var lastMenuProvider: UsageProvider?    var menuProviders: [ObjectIdentifier: UsageProvider] = [:]    var blinkTask: Task<Void, Never>?    var loginTask: Task<Void, Never>? {        didSet { self.refreshMenusForLoginStateChange() }    }     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?    private var cancellables = Set<AnyCancellable>()    var loginPhase: LoginPhase = .idle {        didSet {            if oldValue != self.loginPhase {                self.refreshMenusForLoginStateChange()            }        }    }     let preferencesSelection: PreferencesSelection    var animationDisplayLink: CADisplayLink?    var animationPhase: Double = 0    var animationPattern: LoadingPattern = .knightRider    let loginLogger = Logger(subsystem: "com.steipete.codexbar", category: "login")     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    }     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        for provider in UsageProvider.allCases {            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.statusItems[provider] = item        }        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.store.objectWillChange            .receive(on: RunLoop.main)            .sink { [weak self] _ in                self?.updateIcons()                self?.updateBlinkingState()            }            .store(in: &self.cancellables)         self.store.$debugForceAnimation            .receive(on: RunLoop.main)            .sink { [weak self] _ in                self?.updateVisibility()                self?.updateBlinkingState()            }            .store(in: &self.cancellables)         self.settings.objectWillChange            .receive(on: RunLoop.main)            .sink { [weak self] _ in                self?.updateVisibility()                self?.updateIcons()            }            .store(in: &self.cancellables)    }     private func updateIcons() {        UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: nil) }        self.attachMenus(fallback: self.fallbackProvider)        self.updateAnimationState()        self.updateBlinkingState()    }     private func updateVisibility() {        let fallback = self.fallbackProvider        for provider in UsageProvider.allCases {            let item = self.statusItems[provider]            let isEnabled = self.isEnabled(provider)            let force = self.store.debugForceAnimation            item?.isVisible = isEnabled || fallback == provider || force        }        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.attachMenus(fallback: self.fallbackProvider)    }     private func attachMenus(fallback: UsageProvider? = nil) {        self.menuProviders.removeAll()        for provider in UsageProvider.allCases {            guard let item = self.statusItems[provider] else { continue }            if self.isEnabled(provider) {                item.menu = self.makeMenu(for: provider)            } else if fallback == provider {                item.menu = self.makeMenu(for: nil)            } else {                item.menu = nil            }        }    }     func isVisible(_ provider: UsageProvider) -> Bool {        self.store.debugForceAnimation || self.isEnabled(provider) || self.fallbackProvider == provider    }     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 = switch provider {        case .codex: "Codex"        case .claude: "Claude"        case .gemini: "Gemini"        }        return "\(prefix): \(base)"    }     deinit {        self.blinkTask?.cancel()        self.loginTask?.cancel()        NotificationCenter.default.removeObserver(self)    }}