codeburn/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
iamtoruk 2467578a52 fix(menubar): run prefetchAll in a detached task so the refresh loop starts immediately
prefetchAll was awaited inside the refresh loop after the first cycle.
On large corpora the All Time parse takes 60+ seconds and kept the Today
refresh blocked for that whole window, so the status label got stuck for
minutes at a time while the CLI was returning fresh numbers.

Detach prefetchAll into its own @MainActor task. The popover still gets
the benefit of pre-warmed period caches, the refresh loop ticks every 15
seconds regardless.
2026-04-21 05:22:34 -07:00

198 lines
7.6 KiB
Swift

import SwiftUI
import AppKit
import Observation
private let refreshIntervalSeconds: UInt64 = 15
private let nanosPerSecond: UInt64 = 1_000_000_000
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
/// Fixed so the popover's anchor point doesn't shift each time today's cost changes.
private let statusItemFixedWidth: CGFloat = 130
private let popoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 660
private let menubarTitleFontSize: CGFloat = 13
@main
struct CodeBurnApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
// SwiftUI App needs at least one scene. Settings is invisible by default.
Settings {
EmptyView()
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private let store = AppStore()
let updateChecker = UpdateChecker()
private var refreshTask: Task<Void, Never>?
func applicationDidFinishLaunching(_ notification: Notification) {
// Menubar accessory -- no Dock icon, no app switcher entry.
NSApp.setActivationPolicy(.accessory)
restorePersistedCurrency()
setupStatusItem()
setupPopover()
observeStore()
startRefreshLoop()
Task { await updateChecker.checkIfNeeded() }
}
/// 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)
}
}
}
}
func applicationWillTerminate(_ notification: Notification) {
refreshTask?.cancel()
}
private func startRefreshLoop() {
refreshTask = Task { [weak self] in
guard let s = self else { return }
// First cycle: fetch today so the status icon has a number within seconds of launch.
await s.store.refreshQuietly(period: .today)
s.refreshStatusButton()
await s.store.refresh(includeOptimize: true)
s.refreshStatusButton()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
guard let s = self else { return }
await s.store.refreshQuietly(period: .today)
s.refreshStatusButton()
await s.store.refresh(includeOptimize: true)
s.refreshStatusButton()
}
}
// Prefetch the remaining periods in a detached task. This used to be awaited inside the
// refresh loop, but on large corpora the All Time / Month period can take 60+ seconds to
// parse and blocked Today's refresh for that whole window, so the status label drifted
// out of sync with the CLI until prefetchAll finally returned.
Task { @MainActor [weak self] in
guard let s = self else { return }
await s.store.prefetchAll()
}
}
private func observeStore() {
withObservationTracking {
_ = store.payload
_ = store.todayPayload
} onChange: { [weak self] in
Task { @MainActor in
self?.refreshStatusButton()
self?.observeStore()
}
}
}
// MARK: - Status Item
private func setupStatusItem() {
// Fixed width so the popover anchor (and thus popover position) doesn't shift
// every time today's cost or findings badge changes.
statusItem = NSStatusBar.system.statusItem(withLength: statusItemFixedWidth)
guard let button = statusItem.button else { return }
button.target = self
button.action = #selector(handleButtonClick(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
refreshStatusButton()
}
/// 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 func refreshStatusButton() {
guard let button = statusItem.button else { return }
// Clear any previously-set image so the attachment is the only glyph rendered.
button.image = nil
button.imagePosition = .noImage
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
.withSymbolConfiguration(flameConfig)
flame?.isTemplate = true
let attachment = NSTextAttachment()
attachment.image = flame
if let size = flame?.size {
// Nudge the image down ~2pt so its visual centre sits on the text baseline mid-line
// rather than riding high. Exact value tuned against SF Pro Display 13pt.
attachment.bounds = CGRect(x: 0, y: -2, width: size.width, height: size.height)
}
let hasPayload = store.todayPayload != nil
let valueText = " " + (store.todayPayload?.current.cost.asCompactCurrency() ?? "$—")
let color: NSColor = hasPayload ? .labelColor : .secondaryLabelColor
let composed = NSMutableAttributedString()
composed.append(NSAttributedString(attachment: attachment))
composed.append(NSAttributedString(
string: valueText,
attributes: [.font: font, .foregroundColor: color]
))
button.attributedTitle = composed
}
// 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
let content = MenuBarContent()
.environment(store)
.environment(updateChecker)
.frame(width: popoverWidth)
popover.contentViewController = NSHostingController(rootView: content)
}
@objc private func handleButtonClick(_ sender: AnyObject?) {
guard let button = statusItem.button else { return }
if popover.isShown {
popover.performClose(sender)
} else {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
// MARK: - NSPopoverDelegate
func popoverShouldDetach(_ popover: NSPopover) -> Bool {
false
}
}