import Foundation import CoreGraphics import SwiftUI import AppKit import Observation import ServiceManagement import UserNotifications private let refreshIntervalSeconds: UInt64 = 30 private let forceRefreshWatchdogSeconds: TimeInterval = 90 private let refreshLoopWatchdogSeconds: TimeInterval = 90 private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60 private let refreshRateLimitSeconds: TimeInterval = 5 private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength private let popoverWidth: CGFloat = 360 private let popoverHeight: CGFloat = 660 private let menubarTitleFontSize: CGFloat = 13 enum SubscriptionRefreshBackoff { static let initialDelaySeconds: TimeInterval = TimeInterval(refreshIntervalSeconds) static let maxJitterSeconds: TimeInterval = 5 static func delay( failureCount: Int, cadence: TimeInterval, jitterUnit: Double ) -> TimeInterval { guard cadence > 0 else { return 0 } let exponent = min(max(failureCount, 1) - 1, 10) let exponential = min(cadence, initialDelaySeconds * pow(2, Double(exponent))) let normalizedJitter = min(max(jitterUnit, 0), 1) let jitter = normalizedJitter * min(maxJitterSeconds, cadence) return min(cadence, exponential + jitter) } } @main struct CodeBurnApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { // The Settings scene gives us a real macOS Settings window with the // standard ⌘, shortcut and the menubar "Settings…" item. Provider tabs // (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView. Settings { SettingsView() .environment(delegate.store) .environment(delegate.updateChecker) } } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private var statusItemPlacementRecoveryTask: Task? private var popover: NSPopover! private var capacityDockController: CapacityDockController? private var remoteCommandObserver: DefaultsKeyObserver? private var languageObserver: DefaultsKeyObserver? private var rightClickMonitor: Any? private var lastContextMenuPresentedAt: Date = .distantPast /// Held only while the right-click menu is open. Cleared in menuDidClose so /// left-click goes back to the popover action instead of re-showing the menu. private var contextMenu: NSMenu? fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// Bounds status staleness under App Nap: the 30s timer can be coalesced /// once the app naps (no permanent activity assertion anymore, #647), so /// this system-scheduled activity guarantees a tick attempt every few /// minutes. It goes through the same cadence gate as every other tick. private var napBackstop: NSBackgroundActivityScheduler? private var pendingRefreshWork: DispatchWorkItem? private var refreshTimer: DispatchSourceTimer? private var forceRefreshTask: Task? private var forceRefreshStartedAt: Date? private var forceRefreshGeneration: UInt64 = 0 private var statusPayloadRefreshTask: Task? private var statusPayloadRefreshStartedAt: Date? private var statusPayloadRefreshGeneration: UInt64 = 0 private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 private var claudeQuotaRefreshTask: Task? private var codexQuotaRefreshTask: Task? private var refreshLoopHeartbeatAt: Date = .distantPast private var providerSettingsObserver: NSObjectProtocol? func applicationWillTerminate(_ notification: Notification) { // Bounded by its own timeout, so a slow network can never hold up quit. Telemetry.shared.flushOnQuit() // Synchronously, before the actor hop: the app can exit before a // detached Task is ever scheduled, and a serve child that outlives us // is the orphan in #1117. shutdown() still runs for the tidy case. ServeChildRegistry.shared.reapAll() Task { await ServeConnection.shared.shutdown() } stopStatusItemPlacementRecovery() capacityDockController?.stop() capacityDockController = nil if let providerSettingsObserver { NotificationCenter.default.removeObserver(providerSettingsObserver) self.providerSettingsObserver = nil } if let monitor = rightClickMonitor { NSEvent.removeMonitor(monitor) rightClickMonitor = nil } } func applicationWillFinishLaunching(_ notification: Notification) { // Belt-and-suspenders for dev runs only: packaged builds ship // LSUIElement=true (package-app.sh / build-local.sh), so LaunchServices // starts the process at .accessory before main() runs and this call is // a measured no-op there (returns false, no state change — #868 // analysis). What actually matters is that the policy NEVER // transitions while a live NSStatusItem exists: the .regular → // .accessory dance from #147 is what plausibly caused the ghost item // that 85b5728 fixed. Keep the policy pinned; see the activate() call // in applicationDidFinishLaunching for the other half of the story. NSApp.setActivationPolicy(.accessory) } private func observeSubscriptionDisconnect() { NotificationCenter.default.addObserver( forName: .codeBurnSubscriptionDisconnected, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.resetSubscriptionCadenceAnchor() } } } func applicationDidFinishLaunching(_ notification: Notification) { guard SingleInstanceGuard.enforceSingleInstance() else { return } ProcessInfo.processInfo.automaticTerminationSupportEnabled = false ProcessInfo.processInfo.disableSuddenTermination() // Deliberately NO app-lifetime beginActivity here. A permanent // assertion opted the app out of App Nap forever, which kept the 30s // spawn loop running at full tilt on battery and with the display // off (#647). App Nap coalescing an idle tick is the desired // behavior; each in-flight refresh holds its own short .background // activity so a fetch is never napped mid-flight, and any user // interaction (popover open, wake) refreshes immediately. restorePersistedCurrency() // Start the resident CLI early without an artificial query. The first // real status refresh becomes its only cold warm-up. See ServeConnection. Task { await ServeConnection.shared.ensureStarted() } // #868 experiment: restore only the activation half of the #147 fix. // Packaged builds ship LSUIElement=true, so the policy is .accessory // before main() runs and never transitions (the transition is what // plausibly caused the ghost item). Accessory apps can still activate, // which registers the app with the window server before the status // item is created - the effect #147 actually needed for #146. NSApp.activate(ignoringOtherApps: true) setupStatusItem() setupPopover() capacityDockController = CapacityDockController(store: store) capacityDockController?.start() observeStore() startRefreshLoop() startNapBackstop() setupWakeObservers() removeLegacyRefreshAgent() registerLoginItemIfNeeded() observeRemoteCommands() observeSubscriptionDisconnect() observeCapacityDockProviderSettingsRequests() setupUpdateNotifications() Telemetry.shared.start() updateChecker.settlePendingUpdate() Task { await updateChecker.checkIfNeeded() } } /// Delegate only: authorization is requested lazily by UpdateChecker the /// first time a notification would actually be posted. private func setupUpdateNotifications() { guard Bundle.main.bundleIdentifier != nil else { return } UNUserNotificationCenter.current().delegate = self } private func observeCapacityDockProviderSettingsRequests() { providerSettingsObserver = NotificationCenter.default.addObserver( forName: .capacityDockOpenProviderSettings, object: nil, queue: .main ) { [weak self] notification in guard let providerID = notification.object as? String else { return } Task { @MainActor [weak self] in self?.store.settingsTab = providerID self?.openSettings() } } } private func setupWakeObservers() { // Pause the refresh loop while the machine is asleep. Without this, // Task.sleep keeps a wakeup pending across the suspension and the // loop tick fires the same instant the wake notifications do, // producing 2-3 concurrent CLI spawns within ms of every wake. NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.willSleepNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.stopStatusItemPlacementRecovery() self?.prepareRefreshPipelineForSleep() } } // didWakeNotification + screensDidWakeNotification can both fire on // the same wake. forceRefreshTask squashes overlap; both notifications // still bypass the short manual-click rate limit so a just-before-sleep // refresh cannot block wake recovery. NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") self?.startStatusItemPlacementRecovery() } } NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") self?.startStatusItemPlacementRecovery() } } NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.stopStatusItemPlacementRecovery() } } } private func prepareRefreshPipelineForSleep() { // Leave the timer running: the kernel pauses it during sleep, and tearing // it down stranded the loop whenever a wake notification was missed. forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshStartedAt = nil statusPayloadRefreshGeneration &+= 1 store.resetLoadingState() lastRefreshTime = .distantPast } private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) { if resetLoading { forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 manualRefreshTask?.cancel() manualRefreshTask = nil manualRefreshGeneration &+= 1 statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshStartedAt = nil statusPayloadRefreshGeneration &+= 1 store.resetRefreshState(clearCache: clearCache) } else { _ = store.clearStaleLoadingIfNeeded() } let now = Date() let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds { if refreshTimer != nil { NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) } startRefreshLoop(forceQuotaOnStart: false) } else { // Manual cadence means no automatic spawns, wakes included; the // user refreshes via popover open or Refresh Now. The tick still // runs so stuck-state cleanup happens. runRefreshLoopTick( reason: reason, forcePayload: UsageRefreshCadence.current != .manual, forceQuota: false ) } } // Earlier builds installed a launchd job that re-fetched data out of process. // macOS attributes a launchd-spawned binary differently from the LaunchServices // app, so it triggered its own "access data from other apps" prompt on every // run. Remove any such leftover job on upgrade; the in-app loop is the source of // truth and writes the badge backstop file itself. private func removeLegacyRefreshAgent() { let fm = FileManager.default let home = fm.homeDirectoryForCurrentUser.path let destPath = "\(home)/Library/LaunchAgents/com.codeburn.refresh.plist" guard fm.fileExists(atPath: destPath) else { return } let unload = Process() unload.launchPath = "/bin/launchctl" unload.arguments = ["unload", destPath] try? unload.run() unload.waitUntilExit() try? fm.removeItem(atPath: destPath) } /// The one key the desktop app's Plugins card writes to ask this app to go away. Quitting /// could be a signal, but dropping the login item cannot: `SMAppService.mainApp` only ever /// speaks for the app that calls it, so the app has to unregister itself before it goes. static let remoteCommandKey = "CodeBurnMenubarRemoteCommand" static let loginItemRegisteredKey = "codeburn.loginItemRegistered" private func observeRemoteCommands() { remoteCommandObserver = DefaultsKeyObserver(defaults: .standard, key: Self.remoteCommandKey) { [weak self] in Task { @MainActor [weak self] in self?.handleRemoteCommand() } } // A command written while this app was not running is answered at launch, not ignored. handleRemoteCommand() // The desktop app's language switch writes AppleLanguages into this app's // domain and nothing else. KVO on UserDefaults carries a write made by // another process (that is the whole reason the remote-command key works), // so the switch lands here while the app keeps running. It used to restart // instead, and every restart re-asked a Warp user for "access data from // other apps": that consent lasts only as long as the process does. languageObserver = DefaultsKeyObserver(defaults: .standard, key: LanguagePreference.defaultsKey) { [weak self] in Task { @MainActor [weak self] in self?.applyLanguage() } } } /// Re-point `L(_:)` and refresh everything that is not rebuilt on demand. /// The right-click menu is built from scratch each time it opens, and the /// Settings window and Capacity Dock rail rebuild their SwiftUI content off /// `LanguageGeneration`, which `L10n.use` bumps. That leaves the status item, /// whose title and tooltip are set once per refresh tick, and the popover, /// whose content is built once and kept until it closes. @MainActor private func applyLanguage() { // A language change rebuilds the rail's SwiftUI view, which destroys a // DragGesture in flight before it can report its end. Settle it first, // or the controller stays stuck mid-drag. capacityDockController?.settleActiveDrag() L10n.use(LanguagePreference.current()) if popover?.isShown == true { // refreshStatusButton() refuses to touch the title while the popover // is anchored to the button, and the popover's own content was built // in the old language, so close it: popoverDidClose drops the content // view and refreshes the button. popover.performClose(nil) } else { // setupPopover builds the content once at launch and popoverDidClose // drops it, so the only stale copy is one that has never been shown. popover?.contentViewController = nil } refreshStatusButton() } @MainActor private func handleRemoteCommand() { let defaults = UserDefaults.standard guard let raw = defaults.string(forKey: Self.remoteCommandKey), !raw.isEmpty else { return } // Cleared before acting, so a command that outlives this process cannot quit the next one. defaults.removeObject(forKey: Self.remoteCommandKey) guard let command = MenubarRemoteCommand(rawValue: raw) else { return } if command.unregistersLoginItem { do { if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() } } catch { NSLog("CodeBurn: login item unregister failed: \(error.localizedDescription)") } defaults.removeObject(forKey: Self.loginItemRegisteredKey) } if command == .settings { openSettings() return } guard command.terminates else { return } NSApp.terminate(nil) } private func registerLoginItemIfNeeded() { let key = Self.loginItemRegisteredKey guard !UserDefaults.standard.bool(forKey: key) else { return } // Registers in-process. The old path told System Events to make the login // item, which made macOS ask for Automation access on first launch (#1026). // No AppleScript fallback: a failure here must not bring that prompt back. do { if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() } UserDefaults.standard.set(true, forKey: key) } catch { NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)") } } private var lastRefreshTime: Date = .distantPast /// Anchors the shallow provider-root snapshot only after a complete usage /// refresh succeeds. It sits beside the cadence anchor so a failed fetch /// never makes a later unchanged tick look successful. private var lastSuccessfulUsageDataSnapshot: UsageDataSnapshot? private var lastSuccessfulUsageDataSnapshotAt: Date? private func recordSuccessfulUsageDataSnapshot() { lastSuccessfulUsageDataSnapshot = UsageDataChangeGuard.snapshot() lastSuccessfulUsageDataSnapshotAt = Date() } private func shouldSkipBackgroundUsageRefresh() -> Bool { let current = UsageDataChangeGuard.snapshot() let shouldSkip = UsageDataChangeGuard.shouldSkip( current: current, lastSuccessful: lastSuccessfulUsageDataSnapshot, lastSuccessAt: lastSuccessfulUsageDataSnapshotAt, force: false ) #if DEBUG if shouldSkip { NSLog("CodeBurn: skipping unchanged background usage refresh") } #endif return shouldSkip } @discardableResult private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { if forceRefreshTask != nil { guard let started = forceRefreshStartedAt else { NSLog("CodeBurn: force refresh task had no start timestamp - clearing") forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshGeneration &+= 1 store.resetLoadingState() return true } let elapsed = now.timeIntervalSince(started) guard elapsed > forceRefreshWatchdogSeconds else { return false } NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed)) forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 store.resetLoadingState() return true } return false } @discardableResult private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { if statusPayloadRefreshTask != nil { guard let started = statusPayloadRefreshStartedAt else { NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshGeneration &+= 1 return true } let elapsed = now.timeIntervalSince(started) guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshStartedAt = nil statusPayloadRefreshGeneration &+= 1 return true } return false } private func refreshStatusPayloadIfNeeded( reason: String, force: Bool = false, minAgeSeconds: TimeInterval = 0, qualityOfService: QualityOfService = .userInitiated ) { let now = Date() _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) guard statusPayloadRefreshTask == nil else { return } guard force || store.needsStatusPayloadRefresh else { return } // The 30s cache TTL would otherwise defeat the battery/low-power // backoff through this fallback path: honor the same spawn interval // the main loop uses. A missing payload (nil age) always fetches. if !force, minAgeSeconds > 0, let age = store.menubarPayloadAgeSeconds, TimeInterval(age) < minAgeSeconds { return } let menubarPeriod = store.menubarPeriod if let age = store.menubarPayloadAgeSeconds, age > 120 { NSLog("CodeBurn: status payload stale for %ds on %@ refresh", age, reason) } statusPayloadRefreshStartedAt = now statusPayloadRefreshGeneration &+= 1 let generation = statusPayloadRefreshGeneration statusPayloadRefreshTask = Task { [weak self] in guard let self else { return } let activity = ProcessInfo.processInfo.beginActivity( options: .background, reason: "CodeBurn status refresh") defer { ProcessInfo.processInfo.endActivity(activity) } let succeeded = await self.store.refreshQuietly( period: menubarPeriod, force: true, qualityOfService: qualityOfService ) if succeeded { self.recordSuccessfulUsageDataSnapshot() } self.refreshStatusButton() guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } self.statusPayloadRefreshTask = nil self.statusPayloadRefreshStartedAt = nil } } private func forceRefresh( bypassRateLimit: Bool = false, forceQuota: Bool = false, qualityOfService: QualityOfService = .userInitiated ) { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) if forceRefreshTask != nil { if qualityOfService != .utility || !shouldSkipBackgroundUsageRefresh() { refreshStatusPayloadIfNeeded(reason: "blocked force refresh", qualityOfService: qualityOfService) } } guard forceRefreshTask == nil else { return } if !bypassRateLimit { guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } } lastRefreshTime = now forceRefreshStartedAt = now forceRefreshGeneration &+= 1 let generation = forceRefreshGeneration forceRefreshTask = Task { let activity = ProcessInfo.processInfo.beginActivity( options: .background, reason: "CodeBurn refresh") defer { ProcessInfo.processInfo.endActivity(activity) } async let main = refreshUsagePayloads( force: true, showLoading: true, qualityOfService: qualityOfService ) async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) let mainSucceeded = await main if mainSucceeded { recordSuccessfulUsageDataSnapshot() } refreshStatusButton() _ = await quotas await MainActor.run { [weak self] in guard let self, self.forceRefreshGeneration == generation else { return } self.forceRefreshTask = nil self.forceRefreshStartedAt = nil self.lastRefreshTime = Date() } } } private func refreshUsagePayloads( force: Bool, showLoading: Bool = false, qualityOfService: QualityOfService = .userInitiated ) async -> Bool { let menubarPeriod = store.menubarPeriod // With the popover closed, only the payloads the status item actually // renders are worth a Node spawn: the menubar period, plus today for // the context-menu summary and day views. The popover's selected key // is refreshed by refreshPayloadForPopoverOpen the moment it opens, // so a closed-popover tick never pays for it (#647). if !(popover?.isShown ?? false) { async let menubar = store.refreshMenubarBadge( period: menubarPeriod, force: force, qualityOfService: qualityOfService ) async let today = menubarPeriod != .today ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) : true let (menubarSucceeded, todaySucceeded) = await (menubar, today) return menubarSucceeded && todaySucceeded } let needsMenubarPayload = store.selectedPeriod != menubarPeriod || store.selectedProvider != .all let needsTodayPayload = (store.selectedPeriod != .today || store.selectedProvider != .all) && menubarPeriod != .today async let visible = store.refresh( includeOptimize: false, force: force, showLoading: showLoading, qualityOfService: qualityOfService ) async let menubar = needsMenubarPayload ? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService) : true async let today = needsTodayPayload ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) : true let (visibleSucceeded, menubarSucceeded, todaySucceeded) = await (visible, menubar, today) return visibleSucceeded && menubarSucceeded && todaySucceeded } /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise /// fetched live in the background. private func restorePersistedCurrency() { guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return } let symbol = CurrencyState.symbolForCode(code) store.currency = code Task { let cached = await FXRateCache.shared.cachedRate(for: code) await MainActor.run { CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) } let fresh = await FXRateCache.shared.rate(for: code) if let fresh, fresh != cached { await MainActor.run { CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol) } } } } fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? fileprivate var lastKimiRefreshAt: Date? fileprivate var lastGeminiRefreshAt: Date? fileprivate var lastCopilotRefreshAt: Date? fileprivate var lastAntigravityRefreshAt: Date? fileprivate var lastCapacityDockProviderRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? private var lastQuotaRolloverCheckAt = Date() /// Reset instants crossed since the previous tick, grouped by the force /// flags `refreshLiveQuotaProgressIfDue` takes. Without this a window that /// resets at 14:00 keeps showing its pre-reset percentage until the next /// cadence tick. private func quotaGroupsRolledOver( now: Date = Date() ) -> (claude: Bool, codex: Bool, dock: Bool, any: Bool) { let since = lastQuotaRolloverCheckAt lastQuotaRolloverCheckAt = now func rolledOver(_ dates: [Date?]) -> Bool { QuotaRefreshDecision.windowRolledOver( resetDates: dates.compactMap { $0 }, lastCheckedAt: since, now: now ) } let claude = rolledOver(store.subscription.map { [$0.fiveHourResetsAt, $0.sevenDayResetsAt, $0.sevenDayOpusResetsAt, $0.sevenDaySonnetResetsAt] } ?? []) let codex = rolledOver(store.codexUsage.map { [$0.primary?.resetsAt, $0.secondary?.resetsAt] } ?? []) let dock = rolledOver(store.capacityDockProviderSummaries.values.flatMap { [$0.primary?.resetsAt] + $0.details.map(\.resetsAt) }) return (claude, codex, dock, claude || codex || dock) } @discardableResult private func refreshLiveQuotaProgressIfDue( force: Bool = false, forceClaude: Bool = false, forceCodex: Bool = false, forceCapacityDockProviders: Bool = false ) async -> Bool { let cadence = SubscriptionRefreshCadence.current let autoRefreshAllowed = cadence != .manual if !force && !forceClaude && !forceCodex && !forceCapacityDockProviders && !autoRefreshAllowed { return false } let now = Date() let threshold = TimeInterval(cadence.rawValue) let claudeDue = autoRefreshAllowed && ( nextClaudeQuotaRefreshAt.map { now >= $0 } ?? (now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold) ) let shouldRefreshClaude = force || forceClaude || claudeDue let shouldRefreshCodex = QuotaRefreshDecision.isDue( force: force || forceCodex, autoRefreshAllowed: autoRefreshAllowed, lastAttemptAt: lastCodexRefreshAt, now: now, threshold: threshold ) let shouldRefreshCapacityDockProviders = QuotaRefreshDecision.isDue( force: force || forceCapacityDockProviders, autoRefreshAllowed: autoRefreshAllowed, lastAttemptAt: lastCapacityDockProviderRefreshAt, now: now, threshold: threshold ) guard shouldRefreshClaude || shouldRefreshCodex || shouldRefreshCapacityDockProviders else { return false } if shouldRefreshClaude { // The cadence anchor represents the start of an attempt, even if // the provider fails. Failures get their own shorter backoff below. lastSubscriptionRefreshAt = now } if shouldRefreshCodex { // Attempt anchor, like the generic adapters below: a disconnected // or failing Codex must not force this branch on every tick. lastCodexRefreshAt = now } if shouldRefreshCapacityDockProviders { // Ahead of the Claude/Codex awaits: the dock must not wait on a // slow provider to redraw its rings. lastCapacityDockProviderRefreshAt = now await store.refreshSelectedCapacityDockProviders() } switch (shouldRefreshClaude, shouldRefreshCodex) { case (true, true): async let claude = refreshClaudeQuotaSingleFlight() async let codex = refreshCodexQuotaSingleFlight() let claudeSucceeded = await claude _ = await codex finishClaudeQuotaRefresh( succeeded: claudeSucceeded, attemptedAt: now, cadence: threshold ) case (true, false): let succeeded = await refreshClaudeQuotaSingleFlight() finishClaudeQuotaRefresh(succeeded: succeeded, attemptedAt: now, cadence: threshold) case (false, true): _ = await refreshCodexQuotaSingleFlight() case (false, false): break } await store.quotaCrossingMonitor.record(windows: store.quotaWindows) return true } private func finishClaudeQuotaRefresh( succeeded: Bool, attemptedAt: Date, cadence: TimeInterval ) { guard !succeeded else { claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil return } claudeQuotaFailureCount += 1 let delay = SubscriptionRefreshBackoff.delay( failureCount: claudeQuotaFailureCount, cadence: cadence, jitterUnit: Double.random(in: 0...1) ) nextClaudeQuotaRefreshAt = attemptedAt.addingTimeInterval(delay) } private func refreshClaudeQuotaSingleFlight() async -> Bool { if let task = claudeQuotaRefreshTask { return await task.value } let task = Task { [store] in await store.refreshSubscriptionReportingSuccess() } claudeQuotaRefreshTask = task let result = await task.value if claudeQuotaRefreshTask != nil { claudeQuotaRefreshTask = nil } return result } private func refreshCodexQuotaSingleFlight() async -> Bool { if let task = codexQuotaRefreshTask { return await task.value } // Kimi Code, Gemini, Copilot, and Antigravity ride the same tick, // each with its own cadence anchor: when Codex is not connected its // refresh returns false // immediately, so lastCodexRefreshAt never advances — anchoring them // on it would poll the quota endpoints on every payload tick instead // of the configured quota cadence. Anchor on attempt (not success) // so a failing endpoint also respects the cadence. let kimiDue: Bool = { let cadence = SubscriptionRefreshCadence.current guard cadence != .manual else { return false } return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) }() if kimiDue { lastKimiRefreshAt = Date() } let geminiDue: Bool = { let cadence = SubscriptionRefreshCadence.current guard cadence != .manual else { return false } return Date().timeIntervalSince(lastGeminiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) }() if geminiDue { lastGeminiRefreshAt = Date() } let copilotDue: Bool = { let cadence = SubscriptionRefreshCadence.current guard cadence != .manual else { return false } return Date().timeIntervalSince(lastCopilotRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) }() if copilotDue { lastCopilotRefreshAt = Date() } let antigravityDue: Bool = { let cadence = SubscriptionRefreshCadence.current guard cadence != .manual else { return false } return Date().timeIntervalSince(lastAntigravityRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) }() if antigravityDue { lastAntigravityRefreshAt = Date() } let task = Task { [store, kimiDue, geminiDue, copilotDue, antigravityDue] in async let codex = store.refreshCodexReportingSuccess() if kimiDue { _ = await store.refreshKimiReportingSuccess() } if geminiDue { _ = await store.refreshGeminiReportingSuccess() } if copilotDue { _ = await store.refreshCopilotReportingSuccess() } if antigravityDue { _ = await store.refreshAntigravityReportingSuccess() } return await codex } codexQuotaRefreshTask = task let result = await task.value if codexQuotaRefreshTask != nil { codexQuotaRefreshTask = nil } return result } private func refreshLiveQuotaProgressForPopoverOpen() { let now = Date() let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) let capacityDockElapsed = now.timeIntervalSince(lastCapacityDockProviderRefreshAt ?? .distantPast) let refreshClaude = claudeElapsed >= interactiveQuotaRefreshFloorSeconds let refreshCodex = codexElapsed >= interactiveQuotaRefreshFloorSeconds let refreshCapacityDockProviders = capacityDockElapsed >= interactiveQuotaRefreshFloorSeconds guard refreshClaude || refreshCodex || refreshCapacityDockProviders else { return } Task { [weak self] in guard let self else { return } _ = await self.refreshLiveQuotaProgressIfDue( forceClaude: refreshClaude, forceCodex: refreshCodex, forceCapacityDockProviders: refreshCapacityDockProviders ) } } private func refreshPayloadForPopoverOpen() { // A user viewing the popover is ground truth and must always recover. // Unconditionally ensure the loop is alive, then clear the current // key's stuck loading / in-flight / generation bookkeeping and force a // fresh fetch — even if the cache looks "not stale yet". This is the // guaranteed one-round-trip recovery path. if refreshTimer == nil { startRefreshLoop(forceQuotaOnStart: false) } // The status figure may have gone stale under App Nap while the // popover was closed; catch it up alongside the visible payload. refreshStatusPayloadIfNeeded(reason: "popover open") if store.shouldResetInteractiveRefreshPipeline, let age = store.staleInteractivePayloadAgeSeconds { NSLog("CodeBurn: popover opened with %ds stale payload cache - hard recovery", age) } Task { [weak self] in guard let self else { return } let succeeded = await self.store.recoverFromStuckLoading() if succeeded { self.recordSuccessfulUsageDataSnapshot() } self.refreshStatusButton() } } private func stopRefreshTimer() { refreshTimer?.setEventHandler {} refreshTimer?.cancel() refreshTimer = nil } private func startNapBackstop() { let scheduler = NSBackgroundActivityScheduler( identifier: "org.agentseal.codeburn-menubar.refresh-backstop") scheduler.repeats = true scheduler.interval = 180 scheduler.tolerance = 60 scheduler.qualityOfService = .utility scheduler.schedule { [weak self] completion in Task { @MainActor [weak self] in self?.runRefreshLoopTick(reason: "nap backstop") completion(.finished) } } napBackstop = scheduler } private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { refreshLoopHeartbeatAt = Date() // Display sleep without system sleep (clamshell displays off, screen // saver energy settings) otherwise keeps the full spawn cadence running // for hours (#647). Asked live rather than latched from // screensDidSleep, so a missed wake notification cannot strand it. if !forcePayload && CGDisplayIsAsleep(CGMainDisplayID()) != 0 { return } let hadForceRefreshInFlight = forceRefreshTask != nil let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded() let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded() let clearedStaleLoading = store.clearStaleLoadingIfNeeded() let statusPayloadStale = store.needsStatusPayloadRefresh let sinceLast = Date().timeIntervalSince(lastRefreshTime) // The timer stays at 30s; the spawn interval follows the user's // Settings cadence, stretching on battery / Low Power Mode in Auto // and never auto-spawning in Manual (#647). let interval = currentRefreshInterval() let intervalElapsed = interval.map { sinceLast >= $0 } ?? false // Stuck-refresh cleanup may retrigger a spawn only when the cadence // allows auto-spawns at all: in Manual mode the stale state is // cleared and the next user interaction fetches fresh. let recoveryRespawnAllowed = interval != nil let shouldForceRefresh = forcePayload || ((clearedStaleForceRefresh || clearedStaleLoading) && recoveryRespawnAllowed) || intervalElapsed let popoverOpen = popover?.isShown ?? false let interactiveUsageRefresh = forcePayload || popoverOpen let qualityOfService: QualityOfService = interactiveUsageRefresh ? .userInitiated : .utility var skippedUnchangedUsageRefresh = false if shouldForceRefresh { skippedUnchangedUsageRefresh = !interactiveUsageRefresh && shouldSkipBackgroundUsageRefresh() if !skippedUnchangedUsageRefresh { forceRefresh( bypassRateLimit: true, forceQuota: forceQuota, qualityOfService: qualityOfService ) } } let rolledOver = quotaGroupsRolledOver() if rolledOver.any || QuotaRefreshDecision.needsQuotaOnlyTick( payloadRefreshDue: shouldForceRefresh, payloadSkippedUnchanged: skippedUnchangedUsageRefresh ) { Task { [weak self] in _ = await self?.refreshLiveQuotaProgressIfDue( force: forceQuota, forceClaude: rolledOver.claude, forceCodex: rolledOver.codex, forceCapacityDockProviders: rolledOver.dock ) } } let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { guard forcePayload || interval != nil else { return } if !interactiveUsageRefresh && (skippedUnchangedUsageRefresh || shouldSkipBackgroundUsageRefresh()) { return } refreshStatusPayloadIfNeeded( reason: reason, force: forcePayload, minAgeSeconds: interval ?? 0, qualityOfService: qualityOfService ) } } private func currentRefreshInterval() -> TimeInterval? { RefreshCadence.interval( mode: UsageRefreshCadence.current, popoverOpen: popover?.isShown ?? false, onBattery: PowerSource.isOnBattery(), lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled ) } private func startRefreshLoop(forceQuotaOnStart: Bool = false) { stopRefreshTimer() runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart) let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule( deadline: .now() + .seconds(Int(refreshIntervalSeconds)), repeating: .seconds(Int(refreshIntervalSeconds)), leeway: .seconds(2) ) timer.setEventHandler { [weak self] in Task { @MainActor [weak self] in self?.runRefreshLoopTick(reason: "timer") } } refreshTimer = timer refreshLoopHeartbeatAt = Date() timer.resume() } @MainActor func refreshSubscriptionNow() { manualRefreshTask?.cancel() manualRefreshGeneration &+= 1 let generation = manualRefreshGeneration forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil forceRefreshGeneration &+= 1 statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshStartedAt = nil statusPayloadRefreshGeneration &+= 1 pendingRefreshWork?.cancel() pendingRefreshWork = nil stopRefreshTimer() store.resetRefreshState(clearCache: true) lastRefreshTime = .distantPast refreshStatusButton() manualRefreshTask = Task { [weak self] in guard let self else { return } let activity = ProcessInfo.processInfo.beginActivity( options: .userInitiated, reason: "CodeBurn manual refresh") defer { ProcessInfo.processInfo.endActivity(activity) } // "Refresh Now" should refresh the menubar payload AND every // connected provider's live quota. The user's intent is "make // this match reality right now." async let payload = self.refreshUsagePayloads( force: true, showLoading: true, qualityOfService: .userInitiated ) async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) let payloadSucceeded = await payload if payloadSucceeded { self.recordSuccessfulUsageDataSnapshot() } guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.lastRefreshTime = Date() self.refreshStatusButton() _ = await quotas guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.manualRefreshTask = nil if self.refreshTimer == nil { self.startRefreshLoop() } } } /// Reset the cadence anchor so the next loop tick re-evaluates from "now" /// rather than measuring against a timestamp from the previous connection. /// Triggered on disconnect of any provider — the cost of clearing all /// anchors is one extra refresh tick on the unaffected providers, far less /// disruptive than waiting a full cadence after a reconnect. @MainActor func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil lastKimiRefreshAt = nil lastGeminiRefreshAt = nil lastCopilotRefreshAt = nil lastAntigravityRefreshAt = nil lastCapacityDockProviderRefreshAt = nil claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil } private func observeStore() { // Read closure uses [weak self] so the implicit self capture from // accessing store.* doesn't pin self for the lifetime of an // unfired observation. withObservationTracking is one-shot per // call: once any read property changes, onChange fires and the // registration is consumed, then we re-arm. There is at most one // active subscription at a time. withObservationTracking { [weak self] in guard let self else { return } _ = self.store.payload _ = self.store.menubarPeriod _ = self.store.menubarPayload // Combined-scope badge total: re-render the badge when the cross-device // aggregate for the menubar period lands (or a peer goes reachable). _ = self.store.menubarBadgeCombined // Track currency so the menubar title catches up immediately on // currency switch instead of waiting for the next 30s payload tick. _ = self.store.currency _ = self.store.displayMetric // Second-row settings: the title has to re-render the moment the // toggle or its metric changes, not on the next payload tick. _ = self.store.menubarSecondRowEnabled _ = self.store.menubarSecondRowMetric _ = self.store.dailyBudget _ = self.store.dailyTokenBudget // Read the derived flag so the flame re-tints when today's usage // crosses the budget, not only when the budget value itself changes. // This also makes the dependency on todayPayload explicit instead of // relying on payload/menubarPayload happening to touch the same cache. _ = self.store.isOverDailyBudget // Track the live-quota state too so the flame icon re-tints on // every connected provider's usage update, not just every 30s. _ = self.store.subscription _ = self.store.subscriptionLoadState _ = self.store.codexUsage _ = self.store.codexLoadState _ = self.store.kimiUsage _ = self.store.kimiLoadState _ = self.store.geminiUsage _ = self.store.geminiLoadState _ = self.store.copilotUsage _ = self.store.copilotLoadState _ = self.store.antigravityUsage _ = self.store.antigravityLoadState _ = self.store.capacityDockProviderSummaries _ = self.store.capacityDockProviderErrors _ = self.store.capacityDockProvidersLoading _ = self.store.capacityDockProviderTransientFailures } onChange: { [weak self] in DispatchQueue.main.async { guard let self else { return } self.pendingRefreshWork?.cancel() let work = DispatchWorkItem { [weak self] in self?.refreshStatusButton() self?.seedCapacityDockFromConnectedProviders() self?.capacityDockController?.refreshQuotaPresentation() self?.observeStore() } self.pendingRefreshWork = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) } } } /// Fresh-install default: mirror the connected subscriptions (capped) into /// the dock until the user first edits the set. Cheap — reads cached quota /// state — and no-ops once seeded or once the user takes over. private func seedCapacityDockFromConnectedProviders() { let connected = CapacityDockPreferences.supportedProviders .filter { store.capacityDockProviderIsConnected($0) } CapacityDockPreferences.autoSeedFromConnected(connected) } // MARK: - Status Item private var isCompact: Bool { UserDefaults.standard.bool(forKey: "CodeBurnMenubarCompact") } private func setupStatusItem() { let item = NSStatusBar.system.statusItem(withLength: statusItemWidth) item.autosaveName = StatusItemPlacementPolicy.autosaveName // `autosaveName` makes AppKit restore status-item state across launches. // CodeBurn has no user-facing hide toggle, so explicitly restore the // supported visible state in case Tahoe persisted a hidden/parked item. item.isVisible = true statusItem = item guard let button = statusItem.button else { startStatusItemPlacementRecovery() return } // Set the bundled flame image immediately to ensure the status item renders. // On macOS Tahoe, status items may fail to appear if only an attributed title // is set during initial setup. button.image = Self.menubarFlameImage(tint: nil) button.imagePosition = .imageLeading button.target = self button.action = #selector(handleButtonClick(_:)) // Left-click drives the popover. We keep .rightMouseUp in the mask so the // legacy action path below still fires on macOS <= 26; on macOS 27 the // system consumes the right button entirely and this never fires, so the // global monitor (below) is what restores the right-click menu there. button.sendAction(on: [.leftMouseUp, .rightMouseUp]) // macOS 27 no longer routes any right-mouse event to the status-item // button's target/action. A global monitor still observes right-mouse-up; // we hit-test it against our own status-item window and present the menu // ourselves. // // Must be mouse-*up*, not mouse-down: presenting on down starts menu // tracking while the button is still held, so the matching rightMouseUp // is treated as an outside click and the menu flashes then dismisses. // Presenting on up (after the click completes) keeps it open. Harmless // on 15/26 too (the debounce in showContextMenu prevents a double-present // if the legacy path also fires). rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in guard let self, let button = self.statusItem.button, let window = button.window, window.frame.contains(NSEvent.mouseLocation) else { return } // Defer one turn so menu presentation is not nested inside the // monitor callback. Safe on mouse-*up* (the click is already // complete); on mouse-down a deferred present was killed by the // matching up event and the menu only flashed. DispatchQueue.main.async { self.showContextMenu(from: button) } } // Defer the full attributed title setup to ensure initial render completes DispatchQueue.main.async { [weak self] in self?.refreshStatusButton() self?.startStatusItemPlacementRecovery() } } /// Tahoe can park an accessory app's status item at the screen's top-right /// corner when the auto-hidden menu bar is hidden during launch (#1148). /// Wait for the user's pointer to reveal the bar, then perform up to three /// supported visibility pulses. Never remove/recreate the item: repeated /// creation churn is implicated in poisoning the bundle-id state this /// recovery protects. private func startStatusItemPlacementRecovery() { stopStatusItemPlacementRecovery() statusItemPlacementRecoveryTask = Task { @MainActor [weak self] in guard let self else { return } let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(120)) var recovery = StatusItemPlacementRecoveryCoordinator() while !Task.isCancelled && clock.now < deadline { let placement = self.statusItemPlacementState let menuBar = Self.menuBarState(for: placement.screen) switch recovery.action( for: placement.geometry, isMenuBarRevealed: menuBar.revealed, revealHasSettled: false, menuBarAutoHides: menuBar.autoHides ) { case .stopHealthy: return case .poll, .waitForReveal: try? await Task.sleep(for: .milliseconds(250)) continue case .stopExhausted: NSLog("CodeBurn: status item remains parked after bounded retries") return case .settleBeforePulse: // Let the auto-hide animation finish before asking AppKit // to place the existing item again. try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } let settledPlacement = self.statusItemPlacementState let settledMenuBar = Self.menuBarState(for: settledPlacement.screen) switch recovery.action( for: settledPlacement.geometry, isMenuBarRevealed: settledMenuBar.revealed, revealHasSettled: true, menuBarAutoHides: settledMenuBar.autoHides ) { case .stopHealthy: return case .poll, .waitForReveal, .settleBeforePulse: continue case .stopExhausted: NSLog("CodeBurn: status item remains parked after bounded retries") return case .pulse(let attempt): NSLog("CodeBurn: retrying parked status-item placement after menu bar reveal (\(attempt)/\(recovery.maximumPulseCount))") await StatusItemVisibilityPulse.run { self.statusItem.isVisible = $0 } guard !Task.isCancelled else { return } try? await Task.sleep(for: .milliseconds(250)) continue } case .pulse: // A pulse is only emitted after the settle phase above. assertionFailure("status item pulse emitted before reveal settled") return } } guard !Task.isCancelled else { return } switch self.statusItemPlacementState { case .healthy: return case .unrealized: NSLog("CodeBurn: status item did not realize before placement recovery timed out") case .parked: NSLog("CodeBurn: status item stayed parked without a menu-bar reveal") } } } private static func menuBarState(for screen: NSScreen?) -> (revealed: Bool, autoHides: Bool) { guard let screen else { return (false, true) } return ( StatusItemPlacementPolicy.isMenuBarRevealed( pointer: NSEvent.mouseLocation, screenFrame: screen.frame, screenVisibleFrame: screen.visibleFrame ), StatusItemPlacementPolicy.menuBarAutoHides( screenFrame: screen.frame, screenVisibleFrame: screen.visibleFrame ) ) } private func stopStatusItemPlacementRecovery() { statusItemPlacementRecoveryTask?.cancel() statusItemPlacementRecoveryTask = nil } private enum StatusItemPlacementState { case unrealized case healthy case parked(NSScreen) var geometry: StatusItemPlacementRecoveryGeometry { switch self { case .unrealized: return .unrealized case .healthy: return .healthy case .parked: return .parked } } var screen: NSScreen? { if case .parked(let screen) = self { return screen } return nil } } private var statusItemPlacementState: StatusItemPlacementState { guard let window = statusItem?.button?.window else { return .unrealized } let frame = window.frame guard !frame.isEmpty else { return .unrealized } guard let screen = window.screen ?? NSScreen.screens.first(where: { $0.frame.intersects(frame) }) ?? NSScreen.main else { return .unrealized } let parked = StatusItemPlacementPolicy.isParked( itemFrame: frame, screenFrame: screen.frame, statusBarThickness: NSStatusBar.system.thickness ) return parked ? .parked(screen) : .healthy } /// Composes the menubar title as a single attributed string with the flame as an inline /// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge /// of the status item, the title starts at its own baseline), so we inline both so they /// flow as one typographic unit with a single, controllable gap. private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? { switch severity { case .normal: return nil // template, auto-adapt case .warning: return NSColor.systemYellow // 70-90% case .critical: return NSColor.systemOrange // 90-100% case .danger: return NSColor.systemRed // 100%+ } } /// Loads the menubar flame at the menubar text point size. With no tint it stays a /// template image so the system auto-adapts to the menu bar; a tint returns a /// recolored non-template copy for the budget/quota warning states. private static func menubarFlameImage( tint: NSColor?, pointSize: CGFloat = menubarTitleFontSize ) -> NSImage? { let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium) guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? .withSymbolConfiguration(config) else { return nil } guard let tint else { symbol.isTemplate = true return symbol } let recolored = NSImage(size: symbol.size, flipped: false) { rect in symbol.draw(in: rect) tint.set() rect.fill(using: .sourceAtop) return true } recolored.isTemplate = false return recolored } /// Typography for one rendering of the status-item title. `singleRow` holds /// the historical constants, so the off state of the second-row setting /// produces exactly the string this app has always rendered. private struct MenubarTitleStyle { let fontSize: CGFloat let attachmentPointSize: CGFloat let baselineOffset: CGFloat let attachmentVerticalOffset: CGFloat /// Two rows only. The single row has the whole menu bar to itself, so its /// flame keeps the image size the system hands back, exactly as before. let clampsAttachmentToLineHeight: Bool static let singleRow = MenubarTitleStyle( fontSize: menubarTitleFontSize, attachmentPointSize: menubarTitleFontSize, baselineOffset: -1.0, attachmentVerticalOffset: -3, clampsAttachmentToLineHeight: false ) static let twoRow = MenubarTitleStyle( fontSize: MenubarRowTypography.twoRowFontSize, attachmentPointSize: MenubarRowTypography.twoRowAttachmentPointSize, baselineOffset: MenubarRowTypography.twoRowBaselineOffset, attachmentVerticalOffset: MenubarRowTypography.twoRowAttachmentVerticalOffset, clampsAttachmentToLineHeight: true ) func attachmentBounds(imageSize: CGSize) -> CGRect { guard clampsAttachmentToLineHeight else { return CGRect( x: 0, y: attachmentVerticalOffset, width: imageSize.width, height: imageSize.height ) } return MenubarRowTypography.twoRowAttachmentBounds(imageSize: imageSize) } } /// The button cell's single-line state as AppKit handed it to us, captured /// before the two-row path ever touches it so turning the second row back /// off restores the original rendering rather than a guessed default. private var defaultTitleUsesSingleLineMode: Bool? private var defaultTitleWraps: Bool? private func refreshStatusButton() { guard let button = statusItem.button else { return } // Skip while the popover is anchored to this button. Rewriting the // attributedTitle changes the button's intrinsic width, which makes // macOS reflow the status item in the menubar and detaches the // anchored popover (it pops to a stale default position). The // popoverDidClose delegate calls back through here once the popover // is dismissed so the menubar cost catches up immediately on close. if popover != nil && popover.isShown { return } // Clear any previously-set image so the attachment is the only glyph rendered. button.image = nil button.imagePosition = .noImage // nil whenever the setting is off or the chosen metric has no data, in // which case the title falls back to the single-row composition. // `menubarRowSnapshot` walks every provider's quota summary and reads // the today block, so it is only built once the setting is on: with the // row off a refresh costs no more than the bool it already reads. let rowSettings = store.menubarRowSettings let secondRow = rowSettings.isSecondRowEnabled ? MenubarRowFormatter.secondRow(settings: rowSettings, snapshot: store.menubarRowSnapshot) : nil applyTitleLineMode(to: button, multiline: secondRow != nil) let title = composeStatusTitle( style: secondRow == nil ? .singleRow : .twoRow, secondRow: secondRow ) button.attributedTitle = title // The two-line title is one string with a literal newline in it, which // VoiceOver reads as a single run-on phrase. Spell it out instead. The // single-row state sets nil, so turning the row off restores exactly the // label AppKit derives from the title on its own. button.setAccessibilityLabel( secondRow == nil ? nil : MenubarRowFormatter.accessibilityLabel(title: title.string) ) let menubarPeriod = store.menubarPeriod if let shortfall = store.menubarBadgeDeviceShortfall { button.toolTip = L( "CodeBurn %1$@ · %2$lld of %3$lld devices reporting", menubarPeriod.menubarMetricLabel, shortfall.reachable, shortfall.total ) } else { button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" } persistBadgeStatusFile() } /// A multi-line attributed title only renders once the cell stops forcing a /// single line. Both states are applied explicitly so toggling the setting /// at runtime never leaves the cell in the other mode. private func applyTitleLineMode(to button: NSStatusBarButton, multiline: Bool) { guard let cell = button.cell else { return } if defaultTitleUsesSingleLineMode == nil { defaultTitleUsesSingleLineMode = cell.usesSingleLineMode defaultTitleWraps = cell.wraps } if multiline { cell.usesSingleLineMode = false cell.wraps = true } else { cell.usesSingleLineMode = defaultTitleUsesSingleLineMode ?? false cell.wraps = defaultTitleWraps ?? false } } /// Composes the status-item title. With `secondRow` nil this is the original /// single-row string, byte for byte; with a second row it renders the same /// first line at the two-row point size and appends the extra line under a /// paragraph style that clamps both lines into the menu bar's height. private func composeStatusTitle( style: MenubarTitleStyle, secondRow: String? ) -> NSAttributedString { let font = NSFont.monospacedDigitSystemFont(ofSize: style.fontSize, weight: .regular) // Tint the flame based on the worst-affected connected provider's quota. // Normal (<70%) keeps the template (auto white-on-dark / black-on-light); // warning/critical/danger override with a fixed palette color so the // user gets a glanceable signal even when the menu bar is busy. let aggregate = store.aggregateQuotaStatus var tint = Self.flameTint(for: aggregate.severity) if tint == nil, store.isOverDailyBudget { tint = NSColor.systemYellow } let flame = Self.menubarFlameImage(tint: tint, pointSize: style.attachmentPointSize) let attachment = NSTextAttachment() attachment.image = flame if let size = flame?.size { attachment.bounds = style.attachmentBounds(imageSize: size) } let menubarPeriod = store.menubarPeriod let menubarPayload = badgePayload() let hasPayload = menubarPayload != nil let compact = isCompact let composed = NSMutableAttributedString() composed.append(NSAttributedString(attachment: attachment)) if store.displayMetric != .iconOnly { let suffix = menubarPeriod.menubarSuffix(compact: compact) // Under combined scope the badge shows the cross-device aggregate, so // it matches the popover hero instead of trailing it with the local // figure. Falls back to local when no combined payload is available // (local scope, cold cache, or an unreachable peer). Credits have no // combined total, so that metric always reflects the local device. let badgeCombined = store.menubarBadgeCombined let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens let valueText: String if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens { let outText = formatTokensMenubar(Double(out)) let inpText = formatTokensMenubar(Double(inp)) valueText = compact ? "↑\(outText)↓\(inpText)\(suffix)" : " ↑\(outText) ↓\(inpText)\(suffix)" } else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens { let total = formatTokensMenubar(Double(inp + out)) valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)" } else if store.displayMetric == .credits, let p = menubarPayload?.current { let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) valueText = compact ? "\(credits)cr\(suffix)" : " " + L("%@ credits", credits) + suffix } else { let fallback = compact ? "$-" : "$—" valueText = compact ? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix : " " + (cost?.asCompactCurrency() ?? fallback) + suffix } var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: style.baselineOffset] if !hasPayload { textAttrs[.foregroundColor] = NSColor.secondaryLabelColor } composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) // Combined scope, but a paired device didn't report this cycle: append // a dimmed "reachable/total" so the reduced total reads as "peer // unreachable" rather than a glitch (mirrors the popover's device list). if let shortfall = store.menubarBadgeDeviceShortfall { let marker = " · \(shortfall.reachable)/\(shortfall.total)" let markerAttrs: [NSAttributedString.Key: Any] = [ .font: font, .baselineOffset: style.baselineOffset, .foregroundColor: NSColor.secondaryLabelColor, ] composed.append(NSAttributedString(string: marker, attributes: markerAttrs)) } } guard let secondRow else { return composed } var secondRowAttrs: [NSAttributedString.Key: Any] = [.font: font] if style.baselineOffset != 0 { secondRowAttrs[.baselineOffset] = style.baselineOffset } composed.append(NSAttributedString(string: "\n" + secondRow, attributes: secondRowAttrs)) // Centre both lines against each other and clamp their height: the // status item is only as tall as the menu bar, and an unclamped 9pt // line box (with its natural leading) pushes the pair past 22pt, which // AppKit resolves by clipping the second line away. let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center paragraph.lineBreakMode = .byClipping paragraph.lineSpacing = 0 paragraph.paragraphSpacing = 0 paragraph.minimumLineHeight = MenubarRowTypography.twoRowLineHeight paragraph.maximumLineHeight = MenubarRowTypography.twoRowLineHeight composed.addAttribute( .paragraphStyle, value: paragraph, range: NSRange(location: 0, length: composed.length) ) return composed } private var lastWrittenBadgeGenerated: String? // Mirror the freshest in-memory payload to disk so the badge survives an app // restart. Skips redundant writes by tracking the last payload's `generated` // stamp. This is the only writer now that the launchd fetcher is gone. private func persistBadgeStatusFile() { // Only persist the unscoped (All) badge. Config selection resets to All // on relaunch, so a scoped payload on disk would show the wrong config's // number at next launch. guard store.selectedClaudeConfigSourceId == nil else { return } guard let payload = store.menubarPayload else { return } guard payload.generated != lastWrittenBadgeGenerated else { return } do { try MenubarStatusCache.standard().writeStatus(payload) lastWrittenBadgeGenerated = payload.generated } catch { NSLog("CodeBurn: failed to write badge status file: \(error)") } } // Badge falls back to the on-disk status file (written by a prior app run) // when the in-app loop has no payload yet; in-memory wins when it's fresher. // The 10-min bound discards a file too stale to trust. private func badgePayload() -> MenubarPayload? { let inMemory = store.menubarPayload // While scoped to a config, never fall back to the on-disk badge: it // holds the unscoped All payload and would show the wrong number. if store.selectedClaudeConfigSourceId != nil { return inMemory } let inMemoryAge = store.menubarPayloadAgeSeconds.map(TimeInterval.init) guard let fileRead = MenubarStatusCache.standard().readBadgePayload(maxAgeSeconds: 600) else { return inMemory } if inMemory == nil { return fileRead.payload } if let age = inMemoryAge, fileRead.ageSeconds < age { return fileRead.payload } return inMemory } private func formatTokensMenubar(_ n: Double) -> String { if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) } if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } return String(format: "%.0f", n) } // MARK: - Popover private func setupPopover() { popover = NSPopover() popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight) popover.behavior = .transient // auto-close only on explicit outside click popover.animates = true popover.delegate = self popover.contentViewController = makePopoverContent() } /// Built fresh for every show and dropped on close: a popover content view /// that outlives its window kept re-laying itself out at display cadence /// after being shown once during the first load (6 percent idle CPU until /// relaunch). Rebuilding a 360pt view on open is far cheaper than that. private func makePopoverContent() -> NSViewController { let content = MenuBarContent() .environment(store) .environment(updateChecker) .frame(width: popoverWidth) let hosting = NSHostingController(rootView: content) // Let the popover size itself from preferredContentSize only. With the // default options the hosting view also pushes min/max content sizes at // its window on every pass, and once the popover has been shown while // loading (content 520pt tall, then the real height), the closed // popover keeps re-laying itself out at display cadence: 6 percent idle // CPU until relaunch. hosting.sizingOptions = [.preferredContentSize] return hosting } @objc private func handleButtonClick(_ sender: AnyObject?) { guard let button = statusItem.button, let event = NSApp.currentEvent else { return } // Legacy right-click path for macOS <= 26 (no-op on 27, where the action // never receives a right-mouse event — the global monitor handles it). if event.type == .rightMouseUp { showContextMenu(from: button) return } if popover.isShown { popover.performClose(sender) } else { // Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS // Tahoe an accessory app activating while a popover anchors to // its NSStatusItem can race with the system menu bar's auto-hide // logic and leave the user's apple-menu hidden until the popover // closes. The popover's window takes keyboard focus on its own // via makeKeyAndOrderFront, which is enough for keystrokes to // reach the SwiftUI content. if popover.contentViewController == nil { popover.contentViewController = makePopoverContent() } store.menuPopoverVisible = true Telemetry.shared.track("popover_open") popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) if let window = popover.contentViewController?.view.window { // Pin the popover's window above the status-bar layer but tag // it as auxiliary so macOS Tahoe does not treat it as an // app-level focus event — that's what was hiding the system // menu bar (Terminal's apple-logo / Shell / Edit / View row) // every time the popover opened. window.level = .statusBar window.collectionBehavior.insert(.fullScreenAuxiliary) window.collectionBehavior.insert(.canJoinAllSpaces) window.makeKeyAndOrderFront(nil) } refreshPayloadForPopoverOpen() refreshLiveQuotaProgressForPopoverOpen() } } private func showContextMenu(from button: NSStatusBarButton) { // Debounce: on macOS <= 26 both the legacy action path and the global // monitor can fire for a single right-click. Present at most once per click. // Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802). guard StatusItemContextMenuPolicy.acceptPresent( now: Date(), lastPresentedAt: &lastContextMenuPresentedAt ) else { return } // Don't let an open popover steal the click / sit under the menu. if popover?.isShown == true { popover.performClose(nil) } let menu = NSMenu() // Glanceable "today" usage at the top as a single non-interactive (dimmed) // row. A real .sectionHeader adds section padding (and pulls the actions // into its group without a separator), so use a plain disabled item. let usageItem = NSMenuItem(title: contextMenuUsageSummary(), action: nil, keyEquivalent: "") usageItem.isEnabled = false menu.addItem(usageItem) let settingsItem = NSMenuItem(title: L("Settings…"), action: #selector(openSettings), keyEquivalent: "") settingsItem.target = self settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") menu.addItem(settingsItem) let dockSettingsItem = NSMenuItem(title: L("Capacity Dock Settings…"), action: #selector(openCapacityDockSettings), keyEquivalent: "") dockSettingsItem.target = self dockSettingsItem.image = NSImage(systemSymbolName: "rectangle.trailinghalf.inset.filled.arrow.trailing", accessibilityDescription: "Capacity Dock") menu.addItem(dockSettingsItem) let refreshNow = NSMenuItem(title: L("Refresh Now"), action: #selector(refreshNowAction), keyEquivalent: "") refreshNow.target = self menu.addItem(refreshNow) let updateItem = NSMenuItem(title: L("Check for Updates"), action: #selector(checkForUpdates), keyEquivalent: "") updateItem.target = self menu.addItem(updateItem) let aboutItem = NSMenuItem(title: L("About CodeBurn"), action: #selector(openAbout), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) let quitItem = NSMenuItem(title: L("Quit CodeBurn"), action: #selector(quitApp), keyEquivalent: "") quitItem.target = self menu.addItem(quitItem) // Present via the status item's own menu slot. AppKit positions and tracks // that menu correctly under the status item (no scroll chevron / first-row // jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was // tracked against a point while the cursor still sat on the status item // above it, so the first mouse move engaged scroll mode. // // #472 dropped this pattern because assigning `statusItem.menu` from the // right-mouse *action* never ran on macOS 27 (right-clicks no longer reach // the button action). We still open from our global rightMouseUp monitor // (or the legacy action on ≤26); once we set `statusItem.menu` ourselves, // performClick is just "open the attached menu" and works on 27 too. // // menuDidClose clears `statusItem.menu` so the next left-click hits our // action (popover) instead of re-opening this menu. menu.delegate = self contextMenu = menu statusItem.menu = menu button.performClick(nil) } // MARK: - NSMenuDelegate // AppKit invokes menu callbacks on the main thread. Clear the status-item // menu slot so the next left-click hits our action (popover) again. nonisolated func menuDidClose(_ menu: NSMenu) { // Hop explicitly — don't assumeIsolated across the NSMenuDelegate boundary // under Swift 6 strict concurrency (NSMenu isn't Sendable). DispatchQueue.main.async { [weak self] in guard let self else { return } // Always clear: we only ever attach our own context menu to the item. self.statusItem.menu = nil self.contextMenu = nil } } /// One-line "today" summary for the context menu's usage row. private func contextMenuUsageSummary() -> String { guard let current = store.todayPayload?.current else { return L("Today · no usage yet") } let calls = current.calls == 1 ? L("1 call") : L("%lld calls", current.calls) return L("Today · %1$@ · %2$@", current.cost.asCurrency(), calls) } private var settingsWindowController: NSWindowController? @objc private func openAbout() { store.settingsTab = "about" openSettings() } @objc private func openCapacityDockSettings() { // The Capacity Dock controls live in the General pane; jump straight there. store.settingsTab = "general" openSettings() } @objc private func openSettings() { Telemetry.shared.track("settings_open") // Accessory-policy apps (no Dock icon, no main menu) don't get the // SwiftUI Settings scene wired into the responder chain reliably, so // the standard `showSettingsWindow:` selector silently no-ops. We host // the SwiftUI view in our own NSWindowController instead. if let controller = settingsWindowController { NSApp.activate(ignoringOtherApps: true) controller.window?.makeKeyAndOrderFront(nil) return } let hosting = NSHostingController( rootView: SettingsView().environment(store).environment(updateChecker) ) let window = NSWindow( // The view's own minimum, so the window is never positioned at a // placeholder size that SwiftUI then grows away from the screen. contentRect: NSRect( x: 0, y: 0, width: SettingsView.windowWidth, height: SettingsView.windowHeight ), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) window.title = L("CodeBurn Settings") window.contentViewController = hosting window.isReleasedWhenClosed = false let savedFrame = window.setFrameUsingName(Self.settingsFrameAutosaveName) ? window.frame : nil window.setFrameAutosaveName(Self.settingsFrameAutosaveName) placeSettingsWindow(window, savedFrame: savedFrame) let controller = NSWindowController(window: window) // Cascading would walk the window away from where we just put it. controller.shouldCascadeWindows = false settingsWindowController = controller NSApp.activate(ignoringOtherApps: true) controller.showWindow(nil) // SwiftUI can still resize the window past the initial contentRect after // first layout, and a resize keeps the top-left corner. Place it again // once that settles, at whatever size it ended up. DispatchQueue.main.async { [weak self, weak window] in guard let self, let window else { return } self.placeSettingsWindow(window, savedFrame: savedFrame) } } private static let settingsFrameAutosaveName = "CodeBurnMenubar.SettingsWindow" private func placeSettingsWindow(_ window: NSWindow, savedFrame: NSRect?) { // The popover's screen is the one the user is looking at. let active = statusItem?.button?.window?.screen ?? NSScreen.main guard let active else { return } window.setFrameOrigin(SettingsWindowPlacement.origin( savedFrame: savedFrame, size: window.frame.size, activeVisibleFrame: active.visibleFrame, screenVisibleFrames: NSScreen.screens.map(\.visibleFrame) )) } @objc private func refreshNowAction() { refreshSubscriptionNow() } private func codeburnAlertIcon() -> NSImage? { guard let flame = AboutFlameImage.load(), let icon = flame.copy() as? NSImage else { return nil } icon.size = NSSize(width: 64, height: 64 * flame.size.height / flame.size.width) icon.isTemplate = false return icon } @objc private func checkForUpdates() { Task { await updateChecker.check() let alert = NSAlert() alert.icon = codeburnAlertIcon() if let error = updateChecker.updateError { alert.messageText = L("Update Check Failed") alert.informativeText = error alert.alertStyle = .warning } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { alert.messageText = L("Update Available") let header = L( "%1$@ is available (you have %2$@).", AppVersion.display(latest), AppVersion.display(updateChecker.currentVersion) ) if updateChecker.cliTooOldForUpdate { alert.informativeText = L( "%1$@ Your codeburn CLI is too old to install it. First run:\n\n%2$@\n\nthen:\n\ncodeburn menubar --force", header, updateChecker.cliUpdateCommand ) } else { alert.informativeText = L("%@ Run:\n\ncodeburn menubar --force", header) } alert.alertStyle = .informational } else { alert.messageText = L("Up to Date") alert.informativeText = L( "You're on the latest version (%@).", AppVersion.display(updateChecker.currentVersion) ) alert.alertStyle = .informational } alert.addButton(withTitle: L("OK")) alert.runModal() } } @objc private func quitApp() { NSApp.terminate(nil) } // MARK: - NSPopoverDelegate func popoverShouldDetach(_ popover: NSPopover) -> Bool { false } func popoverDidClose(_ notification: Notification) { store.menuPopoverVisible = false popover.contentViewController = nil // Catch up on any menubar title updates that were skipped while the // popover was anchored. refreshStatusButton() } } extension AppDelegate: UNUserNotificationCenterDelegate { nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { [.banner] } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { // Only the release notice installs anything. Every other poster shares // this delegate, so a tap on one of those must not start an update. guard response.notification.request.identifier .hasPrefix(UpdateChecker.notificationIdentifierPrefix) else { return } await MainActor.run { self.updateChecker.performFullUpdate() } } }