From c350dd2a55d7cd3f048129e9ed0d146e38d8ae2d Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 30 May 2026 13:05:48 -0700 Subject: [PATCH] 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. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 35 ++++---- .../Data/HeadlessRefresh.swift | 31 +++++++ .../Data/MenubarStatusCache.swift | 38 +++------ .../Security/CodeburnCLI.swift | 7 -- .../MenubarRefreshScriptTests.swift | 80 ------------------- .../MenubarStatusCacheTests.swift | 21 ++++- 6 files changed, 75 insertions(+), 137 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift delete mode 100644 mac/Tests/CodeBurnMenubarTests/MenubarRefreshScriptTests.swift 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) + } }