diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 3575840..33e2f20 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -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 = """
@@ -237,8 +237,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
com.codeburn.refresh
ProgramArguments
- /bin/sh
- \(scriptPath)
+ \(binaryPath)
+ --refresh-once
StartInterval
30
@@ -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
diff --git a/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift b/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift
new file mode 100644
index 0000000..099d9c8
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift
@@ -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)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
index 7143cea..6938a2c 100644
--- a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
@@ -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)
}
}
diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
index 13a3e6c..befa3b1 100644
--- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
+++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
@@ -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.. 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)
- }
-}
diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift
index 251766b..c16fd3e 100644
--- a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift
+++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift
@@ -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)
+ }
}