feat(menubar): refresh via headless app binary instead of node script

Run CodeBurn's own signed binary in --refresh-once mode under the LaunchAgent
so the spawned CLI inherits CodeBurn's TCC grant. The bare-node shell script
prompted "node would like to access data from other apps" because launchd-
spawned children lose the signed app's TCC attribution. Drops the generated
shell script, scriptEnvironment, and per-period regeneration; the plist is now
static and period is read from UserDefaults at refresh time.
This commit is contained in:
iamtoruk 2026-05-30 13:05:48 -07:00
parent d9a2e829ae
commit c350dd2a55
6 changed files with 75 additions and 137 deletions

View file

@ -14,6 +14,15 @@ 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
@ -85,7 +94,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
startRefreshLoop()
setupWakeObservers()
setupDistributedNotificationListener()
regenerateRefreshScriptIfNeeded()
installLaunchAgentIfNeeded()
registerLoginItemIfNeeded()
observeSubscriptionDisconnect()
@ -208,26 +216,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
private var lastScriptPeriod: Period?
private func regenerateRefreshScriptIfNeeded() {
let period = store.menubarPeriod
guard lastScriptPeriod != period else { return }
do {
try MenubarStatusCache.standard().writeRefreshScript(period: period)
lastScriptPeriod = period
} catch {
NSLog("CodeBurn: failed to write menubar-refresh.sh: \(error)")
}
}
private func installLaunchAgentIfNeeded() {
let fm = FileManager.default
let agentName = "com.codeburn.refresh.plist"
let home = fm.homeDirectoryForCurrentUser.path
let destPath = "\(home)/Library/LaunchAgents/\(agentName)"
let scriptPath = "\(home)/.cache/codeburn/menubar-refresh.sh"
// 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">
@ -237,8 +237,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
<string>com.codeburn.refresh</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>\(scriptPath)</string>
<string>\(binaryPath)</string>
<string>--refresh-once</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
@ -668,7 +668,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.pendingRefreshWork?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.refreshStatusButton()
self?.regenerateRefreshScriptIfNeeded()
self?.observeStore()
}
self.pendingRefreshWork = work

View file

@ -0,0 +1,31 @@
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 {
let defaults = Bundle.main.bundleIdentifier.flatMap { UserDefaults(suiteName: $0) } ?? .standard
let period = Period.savedMenubarPeriod(defaults: defaults)
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)
}
}

View file

@ -1,21 +1,17 @@
import Foundation
/// On-disk badge backstop. A static LaunchAgent runs `menubar-refresh.sh` every
/// 30s and atomically writes `menubar-status.json`; the app reads it 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. 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.
struct MenubarStatusCache {
let statusPath: String
let scriptPath: String
/// Default locations under `~/.cache/codeburn/`.
/// Default location under `~/.cache/codeburn/`.
static func standard() -> MenubarStatusCache {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let dir = "\(home)/.cache/codeburn"
return MenubarStatusCache(
statusPath: "\(dir)/menubar-status.json",
scriptPath: "\(dir)/menubar-refresh.sh"
)
return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json")
}
struct BadgeRead {
@ -41,22 +37,8 @@ struct MenubarStatusCache {
return BadgeRead(payload: payload, ageSeconds: age)
}
// Interpolated values are app-controlled: argv via CodeburnCLI.scriptEnvironment
// (isSafe-validated) and period via the Period enum's fixed cliArg set.
func writeRefreshScript(period: Period) throws {
let env = CodeburnCLI.scriptEnvironment()
// Left unquoted on purpose: a multi-token argv (e.g. "node /path/cli.js")
// must word-split into separate argv elements. isSafe excludes all shell
// metacharacters, so the only thing a token can add is extra words.
let binCommand = env.argv.joined(separator: " ")
let tmpPath = statusPath + ".tmp"
let body = """
#!/bin/sh
export PATH="\(env.path)"
TMP="\(tmpPath)"
OUT="\(statusPath)"
\(binCommand) status --format menubar-json --provider all --period \(period.cliArg) --no-optimize > "$TMP" 2>/dev/null && mv -f "$TMP" "$OUT" || rm -f "$TMP"
"""
try SafeFile.write(Data(body.utf8), to: scriptPath, mode: 0o700)
func writeStatus(_ payload: MenubarPayload) throws {
let data = try JSONEncoder().encode(payload)
try SafeFile.write(data, to: statusPath, mode: 0o600)
}
}

View file

@ -99,13 +99,6 @@ enum CodeburnCLI {
return process
}
// argv tokens are `isSafe`-validated and PATH entries are constants, so the
// result is safe to interpolate directly into the LaunchAgent shell script.
static func scriptEnvironment() -> (argv: [String], path: String) {
let basePath = ProcessInfo.processInfo.environment["PATH"] ?? ""
return (baseArgv(), augmentedPath(basePath))
}
static func isSafe(_ s: String) -> Bool {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
return safeArgPattern.firstMatch(in: s, range: range) != nil

View file

@ -1,80 +0,0 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Menubar refresh script")
struct MenubarRefreshScriptTests {
@Test("scriptEnvironment exposes a safe argv and augmented PATH")
func scriptEnvironmentIsSafe() {
let env = CodeburnCLI.scriptEnvironment()
#expect(!env.argv.isEmpty)
#expect(env.argv.allSatisfy { CodeburnCLI.isSafe($0) })
// Homebrew + /usr/local are always appended for GUI-launched apps.
#expect(env.path.contains("/opt/homebrew/bin"))
#expect(env.path.contains("/usr/local/bin"))
}
private func tempDir() -> String {
let dir = NSTemporaryDirectory() + "menubar-script-test-" + UUID().uuidString
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
@Test("generated script targets the status file and the requested period")
func scriptContainsExpectedArgv() throws {
let dir = tempDir()
let statusPath = dir + "/menubar-status.json"
let scriptPath = dir + "/menubar-refresh.sh"
let cache = MenubarStatusCache(statusPath: statusPath, scriptPath: scriptPath)
try cache.writeRefreshScript(period: .sevenDays)
let body = try String(contentsOfFile: scriptPath, encoding: .utf8)
#expect(body.contains("status --format menubar-json --provider all --period week --no-optimize"))
#expect(body.contains(statusPath))
#expect(body.contains("mv -f"))
let perms = try FileManager.default.attributesOfItem(atPath: scriptPath)[.posixPermissions] as? NSNumber
#expect(perms?.int16Value == 0o700)
}
@Test("running a generated-style script produces a readable status file")
func scriptRoundTripsThroughStatusFile() throws {
let dir = tempDir()
let statusPath = dir + "/menubar-status.json"
let tmpPath = statusPath + ".tmp"
let scriptPath = dir + "/menubar-refresh.sh"
let stubPath = dir + "/codeburn"
let minimalJSON = #"""
{"generated":"2026-05-29T00:00:00Z","current":{"label":"Today","cost":3.21,"calls":1,"sessions":1,"inputTokens":1,"outputTokens":1},"optimize":{"findingCount":0,"savingsUSD":0,"topFindings":[]},"history":{"daily":[]}}
"""#
let stub = """
#!/bin/sh
cat <<'JSON'
\(minimalJSON)
JSON
"""
try stub.write(toFile: stubPath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: stubPath)
let scriptBody = """
#!/bin/sh
TMP="\(tmpPath)"
OUT="\(statusPath)"
"\(stubPath)" status --format menubar-json --provider all --period today --no-optimize > "$TMP" 2>/dev/null && mv -f "$TMP" "$OUT" || rm -f "$TMP"
"""
try scriptBody.write(toFile: scriptPath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o700)], ofItemAtPath: scriptPath)
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
proc.arguments = [scriptPath]
try proc.run()
proc.waitUntilExit()
#expect(proc.terminationStatus == 0)
let cache = MenubarStatusCache(statusPath: statusPath, scriptPath: scriptPath)
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
#expect(result?.payload.current.cost == 3.21)
}
}

View file

@ -33,7 +33,7 @@ struct MenubarStatusCacheTests {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try validPayloadJSON(cost: 42.5).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/menubar-refresh.sh")
let cache = MenubarStatusCache(statusPath: path)
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
@ -44,7 +44,7 @@ struct MenubarStatusCacheTests {
@Test("returns nil for a missing file")
func missingFileReturnsNil() {
let dir = tempDir()
let cache = MenubarStatusCache(statusPath: dir + "/nope.json", scriptPath: dir + "/s.sh")
let cache = MenubarStatusCache(statusPath: dir + "/nope.json")
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@ -53,7 +53,7 @@ struct MenubarStatusCacheTests {
let dir = tempDir()
let path = dir + "/menubar-status.json"
try Data("{ not json".utf8).write(to: URL(fileURLWithPath: path))
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/s.sh")
let cache = MenubarStatusCache(statusPath: path)
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@ -66,7 +66,20 @@ struct MenubarStatusCacheTests {
try FileManager.default.setAttributes(
[.modificationDate: Date().addingTimeInterval(-7200)], ofItemAtPath: path
)
let cache = MenubarStatusCache(statusPath: path, scriptPath: dir + "/s.sh")
let cache = MenubarStatusCache(statusPath: path)
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
}
@Test("writeStatus round-trips through readBadgePayload")
func writeStatusRoundTrips() throws {
let dir = tempDir()
let path = dir + "/menubar-status.json"
let cache = MenubarStatusCache(statusPath: path)
let payload = try JSONDecoder().decode(MenubarPayload.self, from: validPayloadJSON(cost: 13.5))
try cache.writeStatus(payload)
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
#expect(result?.payload.current.cost == 13.5)
}
}