mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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:
parent
d9a2e829ae
commit
c350dd2a55
6 changed files with 75 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
31
mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift
Normal file
31
mac/Sources/CodeBurnMenubar/Data/HeadlessRefresh.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue