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:
Resham Joshi 2026-04-17 16:55:56 -07:00
parent 69268a9e91
commit 495a254338
46 changed files with 6433 additions and 575 deletions

View file

@ -0,0 +1,59 @@
import Foundation
/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the
/// binary argv is validated once and no code path ever passes user-influenced strings through
/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack
/// surface end-to-end.
enum CodeburnCLI {
/// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen,
/// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks,
/// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through.
private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$")
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
/// Homebrew and npm global installs.
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
/// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only
/// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
/// plain `codeburn` name (resolved via PATH).
static func baseArgv() -> [String] {
guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
return ["codeburn"]
}
let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard parts.allSatisfy(isSafe) else {
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
return ["codeburn"]
}
return parts
}
/// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
/// so PATH lookup happens without involving a shell, and augments PATH with Homebrew
/// defaults. Caller sets stdout/stderr pipes and calls `run()`.
static func makeProcess(subcommand: [String]) -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = augmentedPath(environment["PATH"] ?? "")
process.environment = environment
// `env --` treats everything following as argv, not VAR=val pairs -- guards against an
// argument accidentally resembling an env assignment.
process.arguments = ["--"] + baseArgv() + subcommand
return process
}
static func isSafe(_ s: String) -> Bool {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
return safeArgPattern.firstMatch(in: s, range: range) != nil
}
private static func augmentedPath(_ existing: String) -> String {
var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
for extra in additionalPathEntries where !parts.contains(extra) {
parts.append(extra)
}
return parts.joined(separator: ":")
}
}