From 515a7a14673499a209d64f635b26c7e76ec5f691 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 30 May 2026 13:35:07 -0700 Subject: [PATCH] feat(menubar): drop launchd fetcher; GUI writes its own badge backstop A launchd-spawned process gets separate TCC attribution from the LaunchServices app, so the out-of-process refresher prompted for "access data from other apps" on every run regardless of the GUI's grant. Remove it entirely: the in-app loop (timer survives sleep, popover-open recovery) is the source of truth and now writes menubar-status.json on each successful refresh. On upgrade, any leftover com.codeburn.refresh LaunchAgent is unloaded and deleted. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 99 +++++++------------ .../Data/HeadlessRefresh.swift | 31 ------ .../Data/MenubarStatusCache.swift | 9 +- 3 files changed, 40 insertions(+), 99 deletions(-) delete mode 100644 mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 33e2f20f..cf33549a 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -14,15 +14,6 @@ private let popoverHeight: CGFloat = 660 private let menubarTitleFontSize: CGFloat = 13 @main -enum CodeBurnEntry { - static func main() { - if CommandLine.arguments.dropFirst().contains("--refresh-once") { - HeadlessRefresh.run() - } - CodeBurnApp.main() - } -} - struct CodeBurnApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate @@ -94,7 +85,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { startRefreshLoop() setupWakeObservers() setupDistributedNotificationListener() - installLaunchAgentIfNeeded() + removeLegacyRefreshAgent() registerLoginItemIfNeeded() observeSubscriptionDisconnect() Task { await updateChecker.checkIfNeeded() } @@ -216,59 +207,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - private func installLaunchAgentIfNeeded() { + // 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 agentName = "com.codeburn.refresh.plist" let home = fm.homeDirectoryForCurrentUser.path - let destPath = "\(home)/Library/LaunchAgents/\(agentName)" + let destPath = "\(home)/Library/LaunchAgents/com.codeburn.refresh.plist" + guard fm.fileExists(atPath: destPath) else { return } - // Run the app's own signed binary headless so the CLI it spawns inherits - // CodeBurn's TCC grant instead of prompting as a bare `node` process. - guard let binaryPath = Bundle.main.executablePath else { - NSLog("CodeBurn: no executablePath; skipping LaunchAgent install") - return - } - let plist = """ - - - - - Label - com.codeburn.refresh - ProgramArguments - - \(binaryPath) - --refresh-once - - StartInterval - 30 - RunAtLoad - - - -""" - - do { - let existing = try? String(contentsOfFile: destPath, encoding: .utf8) - if existing == plist { return } - - try fm.createDirectory(atPath: "\(home)/Library/LaunchAgents", withIntermediateDirectories: true) - try plist.write(toFile: destPath, atomically: true, encoding: .utf8) - - let unload = Process() - unload.launchPath = "/bin/launchctl" - unload.arguments = ["unload", destPath] - try? unload.run() - unload.waitUntilExit() - - let load = Process() - load.launchPath = "/bin/launchctl" - load.arguments = ["load", destPath] - try load.run() - load.waitUntilExit() - } catch { - NSLog("CodeBurn: LaunchAgent setup failed: \(error)") - } + let unload = Process() + unload.launchPath = "/bin/launchctl" + unload.arguments = ["unload", destPath] + try? unload.run() + unload.waitUntilExit() + try? fm.removeItem(atPath: destPath) } private func registerLoginItemIfNeeded() { @@ -797,11 +752,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.attributedTitle = composed button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + + persistBadgeStatusFile() } - // Badge falls back to the launchd-written status file when the in-app loop - // is dead or behind; in-memory wins when it's fresher. The 10-min bound - // discards a file the 30s job has stopped updating. + 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() { + 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 let inMemoryAge = store.menubarPayloadAgeSeconds.map(TimeInterval.init) diff --git a/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift b/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift deleted file mode 100644 index ce9eb921..00000000 --- a/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -// Runs one CLI fetch and writes menubar-status.json, then exits. Invoked by the -// LaunchAgent via the app's own signed binary so the spawned CLI inherits -// CodeBurn's TCC grant instead of prompting as a bare `node` process. -enum HeadlessRefresh { - // The semaphore provides the happens-before edge between the Task's write and - // the synchronous read below, so this single-slot box is safe to share. - private final class ExitBox: @unchecked Sendable { - var code: Int32 = 1 - } - - static func run() -> Never { - let semaphore = DispatchSemaphore(value: 0) - let box = ExitBox() - Task { - do { - // Same bundle ID as the GUI app, so .standard is its own domain. - let period = Period.savedMenubarPeriod() - let payload = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) - try MenubarStatusCache.standard().writeStatus(payload) - box.code = 0 - } catch { - FileHandle.standardError.write(Data("CodeBurn refresh-once failed: \(error)\n".utf8)) - } - semaphore.signal() - } - semaphore.wait() - exit(box.code) - } -} diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift index 6938a2ce..ef5f217a 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift @@ -1,10 +1,9 @@ import Foundation -/// On-disk badge backstop. A static LaunchAgent runs the app's own signed binary -/// in `--refresh-once` mode every 30s; it atomically writes `menubar-status.json`, -/// which the app reads as a badge fallback when the in-app refresh loop is behind -/// or dead. Shares the `MenubarPayload` decoder with the live path — no separate -/// data model. +/// On-disk badge backstop. The app writes `menubar-status.json` on each successful +/// refresh and reads it back as a badge fallback after a restart, before the live +/// loop has produced a payload. Shares the `MenubarPayload` model with the live +/// path — no separate data model. struct MenubarStatusCache { let statusPath: String