mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 15:09:43 +00:00
feat(mac): native Swift menubar app + one-command install
Introduces mac/ with a native SwiftUI menubar app that replaces the previous SwiftBar plugin entirely. Install via `npx codeburn menubar`, which downloads the .app from GitHub Releases, strips Gatekeeper quarantine, and drops it into ~/Applications. Highlights - mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity + model breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub banner, live 60s refresh, instant currency switching with offline FX cache. - Security: CodeburnCLI argv-based spawn (no shell interpretation), SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to [0.0001, 1_000_000], keychain filtered to account == "default", removed byte-window credential log, in-flight refresh guard, POSIX flock on config.json writes, TerminalLauncher validates argv before AppleScript interpolation. - Performance: shared static NumberFormatter (thousands of allocations per popover redraw eliminated), concurrent pipe drain with 20 MB cap + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min payload cache keyed on (period, provider). - CLI: new `codeburn menubar` subcommand that downloads + installs + launches the .app (no clone, no build). New `status --format menubar-json` payload builder. `export` rewritten to produce a folder of one-table-per-file CSVs with a `.codeburn-export` marker so arbitrary -o paths cannot be silently deleted. - Removed: src/menubar.ts (SwiftBar plugin generator), install-menubar / uninstall-menubar subcommands, `status --format menubar` directive output, tests/menubar.test.ts, tests/security/menubar-injection.test.ts. - Release: .github/workflows/release-menubar.yml builds universal binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag push. Runs on the free macos-latest runner. Tests - 230 TypeScript tests pass - 10 Swift CapacityEstimator tests pass - TypeScript typecheck clean - Swift release build clean
This commit is contained in:
parent
69268a9e91
commit
495a254338
46 changed files with 6433 additions and 575 deletions
107
mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Normal file
107
mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import Foundation
|
||||
|
||||
/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
|
||||
/// (365 days of history with dozens of models); anything larger is pathological and truncating
|
||||
/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
|
||||
/// Pipe file descriptors pinned forever.
|
||||
private let maxPayloadBytes = 20 * 1024 * 1024
|
||||
private let maxStderrBytes = 256 * 1024
|
||||
private let spawnTimeoutSeconds: UInt64 = 60
|
||||
|
||||
enum DataClientError: Error {
|
||||
case spawn(String)
|
||||
case nonZeroExit(code: Int32, stderr: String)
|
||||
case decode(Error)
|
||||
case timeout
|
||||
case outputTooLarge
|
||||
}
|
||||
|
||||
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
|
||||
/// commands through `/bin/zsh -c` anymore.
|
||||
struct DataClient {
|
||||
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
|
||||
var subcommand = [
|
||||
"status",
|
||||
"--format", "menubar-json",
|
||||
"--period", period.cliArg,
|
||||
"--provider", provider.cliArg,
|
||||
]
|
||||
if !includeOptimize {
|
||||
subcommand.append("--no-optimize")
|
||||
}
|
||||
|
||||
let result = try await runCLI(subcommand: subcommand)
|
||||
guard result.exitCode == 0 else {
|
||||
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
|
||||
} catch {
|
||||
throw DataClientError.decode(error)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessResult {
|
||||
let stdout: Data
|
||||
let stderr: String
|
||||
let exitCode: Int32
|
||||
}
|
||||
|
||||
private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
|
||||
let process = CodeburnCLI.makeProcess(subcommand: subcommand)
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw DataClientError.spawn(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Drain both pipes concurrently so a large stderr can't deadlock stdout (the child
|
||||
// blocks on write once the pipe buffer fills). `drain` also enforces a byte cap.
|
||||
async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes)
|
||||
async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes)
|
||||
|
||||
// Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
|
||||
let timeoutTask = Task.detached(priority: .utility) {
|
||||
try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
let (out, err) = await (stdoutData, stderrData)
|
||||
process.waitUntilExit()
|
||||
|
||||
if out.count >= maxPayloadBytes {
|
||||
throw DataClientError.outputTooLarge
|
||||
}
|
||||
|
||||
let stderrString = String(data: err, encoding: .utf8) ?? ""
|
||||
return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
|
||||
}
|
||||
|
||||
/// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which
|
||||
/// returns empty on EOF -- no blocking once the child exits.
|
||||
private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
|
||||
await Task.detached(priority: .utility) {
|
||||
var buffer = Data()
|
||||
while buffer.count < limit {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break }
|
||||
let remaining = limit - buffer.count
|
||||
if chunk.count > remaining {
|
||||
buffer.append(chunk.prefix(remaining))
|
||||
break
|
||||
}
|
||||
buffer.append(chunk)
|
||||
}
|
||||
return buffer
|
||||
}.value
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue