From e970f64cbb731431c5b30ac840062ead87692f94 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 23 Apr 2026 02:25:20 +0200 Subject: [PATCH] fix: menubar auto refresh stops working after app is idle The menubar price would freeze after the app was idle for a while because macOS App Nap suspends Task.sleep even with beginActivity. Replace Task.sleep with DispatchSourceTimer which is more robust for background execution. Also add observers for system wake and screen wake events to force a refresh when the Mac resumes from sleep. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 1b3bd4e..e31ac74 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -28,11 +28,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var popover: NSPopover! private let store = AppStore() let updateChecker = UpdateChecker() - private var refreshTask: Task? + private var dispatchTimer: DispatchSourceTimer? /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. - /// Without this the 15s refresh Task gets suspended whenever the user is interacting with - /// another app, and the status bar label freezes until they click the menubar icon (which - /// calls NSApp.activate and wakes the app back up). private var backgroundActivity: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { @@ -50,9 +47,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { setupPopover() observeStore() startRefreshLoop() + setupWakeObservers() Task { await updateChecker.checkIfNeeded() } } + private func setupWakeObservers() { + // Force refresh when system wakes from sleep + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.forceRefresh() + } + + // Force refresh when screen wakes + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.forceRefresh() + } + } + + private func forceRefresh() { + Task { @MainActor in + await store.refreshQuietly(period: .today) + refreshStatusButton() + await store.refresh(includeOptimize: true) + refreshStatusButton() + } + } + /// 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. @@ -76,34 +103,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } func applicationWillTerminate(_ notification: Notification) { - refreshTask?.cancel() + dispatchTimer?.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() - } + // Initial fetch on launch + Task { + await store.refreshQuietly(period: .today) + refreshStatusButton() + await store.refresh(includeOptimize: true) + refreshStatusButton() } - // Period tabs are fetched lazily when the user first clicks them in the popover. - // An earlier version prefetched every period on launch to make tab switching instant, - // but on large session corpora that spawned four concurrent codeburn subprocesses - // competing with the main refresh loop for disk and parser time, and the status label - // drifted stale for minutes. A per-tab first-click cost of a few seconds is the better - // tradeoff on user machines that track thousands of sessions. + // Use DispatchSourceTimer for more reliable background execution + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + .seconds(Int(refreshIntervalSeconds)), repeating: .seconds(Int(refreshIntervalSeconds)), leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self = self else { return } + Task { @MainActor in + await self.store.refreshQuietly(period: .today) + self.refreshStatusButton() + await self.store.refresh(includeOptimize: true) + self.refreshStatusButton() + } + } + timer.resume() + dispatchTimer = timer } private func observeStore() {