mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-20 05:54:25 +00:00
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.
This commit is contained in:
parent
a2ab52aafd
commit
515a7a1467
3 changed files with 40 additions and 99 deletions
|
|
@ -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 = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.codeburn.refresh</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>\(binaryPath)</string>
|
||||
<string>--refresh-once</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>30</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue