diff --git a/contrib/macos-app/.gitignore b/contrib/macos-app/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/contrib/macos-app/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/contrib/macos-app/CodexQuota.swift b/contrib/macos-app/CodexQuota.swift new file mode 100644 index 00000000..0366433b --- /dev/null +++ b/contrib/macos-app/CodexQuota.swift @@ -0,0 +1,254 @@ +import SwiftUI + +// MARK: - Data model + +struct QuotaWindow { + var remaining: Int // percent remaining + var resetAtMs: Double? +} + +struct AccountQuota: Identifiable { + let id: String // accountId + let name: String // email local-part + var isActive: Bool + var fiveHour: QuotaWindow? + var sevenDay: QuotaWindow? +} + +final class QuotaModel: ObservableObject { + @Published var accounts: [AccountQuota] = [] + @Published var updating = false + @Published var lastUpdated: Date? + + private let dataDir: String = { + if let override = ProcessInfo.processInfo.environment["CODEX_MULTI_AUTH_DIR"] { + return override + } + return NSHomeDirectory() + "/.codex/multi-auth" + }() + + private func json(_ name: String) -> [String: Any]? { + let path = dataDir + "/" + name + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return obj + } + + /// Read the three local files and rebuild `accounts`. No network, no probe. + func loadCache() { + guard let store = json("openai-codex-accounts.json") else { return } + let cache = json("quota-cache.json") ?? [:] + let observ = json("runtime-observability.json") ?? [:] + let byId = cache["byAccountId"] as? [String: Any] ?? [:] + + let storeAccounts = store["accounts"] as? [[String: Any]] ?? [] + let order = storeAccounts.compactMap { $0["accountId"] as? String } + + var activeId = observ["lastAccountId"] as? String + if activeId == nil || !order.contains(activeId!) { + if let idx = store["activeIndex"] as? Int, idx >= 0, idx < order.count { + activeId = order[idx] + } + } + + func window(_ entry: [String: Any]?, _ key: String) -> QuotaWindow? { + guard let w = entry?[key] as? [String: Any] else { return nil } + let used = (w["usedPercent"] as? NSNumber)?.intValue ?? 0 + let reset = (w["resetAtMs"] as? NSNumber)?.doubleValue + return QuotaWindow(remaining: 100 - used, resetAtMs: reset) + } + + var newest: Double = 0 + var result: [AccountQuota] = [] + for acc in storeAccounts { + guard let id = acc["accountId"] as? String else { continue } + let email = acc["email"] as? String ?? id + let name = email.split(separator: "@").first.map(String.init) ?? email + let entry = byId[id] as? [String: Any] + if let u = (entry?["updatedAt"] as? NSNumber)?.doubleValue { newest = max(newest, u) } + result.append(AccountQuota( + id: id, + name: name, + isActive: id == activeId, + fiveHour: window(entry, "primary"), + sevenDay: window(entry, "secondary") + )) + } + DispatchQueue.main.async { + self.accounts = result + if newest > 0 { self.lastUpdated = Date(timeIntervalSince1970: newest / 1000) } + } + } + + /// Run `codex-multi-auth check` (a live probe), then reload the cache. + func refresh() { + if updating { return } + updating = true + DispatchQueue.global(qos: .userInitiated).async { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/zsh") + task.arguments = ["-lc", "codex-multi-auth check"] + var env = ProcessInfo.processInfo.environment + let home = NSHomeDirectory() + env["PATH"] = "/usr/local/bin:/opt/homebrew/bin:\(home)/.npm-global/bin:" + (env["PATH"] ?? "") + task.environment = env + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + self.loadCache() + DispatchQueue.main.async { self.updating = false } + } + } + + /// Modification time of the quota cache file. Used as the staleness signal + /// because it always exists and is read synchronously — unlike `lastUpdated`, + /// which is derived from an optional `updatedAt` field and committed + /// asynchronously, so it can lag a render or never advance from nil. + private func cacheFileMTime() -> Date? { + let path = dataDir + "/quota-cache.json" + guard let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let date = attrs[.modificationDate] as? Date else { return nil } + return date + } + + /// Probe only if the cache file is older than `maxAge`; otherwise just repaint. + func refreshIfStale(maxAge: TimeInterval = 60) { + loadCache() + if let mtime = cacheFileMTime(), Date().timeIntervalSince(mtime) < maxAge { return } + refresh() + } + + var titleRemaining: Int? { + if let active = accounts.first(where: { $0.isActive }), let w = active.fiveHour { + return w.remaining + } + return accounts.first?.fiveHour?.remaining + } +} + +// MARK: - Formatting helpers + +func formatReset(_ ms: Double?) -> String { + guard let ms else { return "-" } + let left = Int(ms / 1000 - Date().timeIntervalSince1970) + if left <= 0 { return "now" } + let d = left / 86400, h = (left % 86400) / 3600, m = (left % 3600) / 60 + if d > 0 { return "\(d)d \(h)h" } + if h > 0 { return "\(h)h \(m)m" } + return "\(m)m" +} + +func quotaColor(_ remaining: Int) -> Color { + if remaining < 10 { return .red } + if remaining < 30 { return .orange } + return .green +} + +func ageString(_ date: Date?) -> String { + guard let date else { return "—" } + let mins = Int(Date().timeIntervalSince(date) / 60) + if mins < 1 { return "just now" } + if mins < 60 { return "\(mins)m ago" } + return "\(mins / 60)h ago" +} + +// MARK: - Views + +struct WindowRow: View { + let label: String + let window: QuotaWindow? + + var body: some View { + HStack(spacing: 8) { + Text(label).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary).frame(width: 20, alignment: .leading) + if let w = window { + ProgressView(value: Double(w.remaining), total: 100) + .tint(quotaColor(w.remaining)) + .frame(width: 120) + Text("\(w.remaining)%").font(.system(.caption, design: .monospaced).weight(.medium)).foregroundStyle(quotaColor(w.remaining)).frame(width: 38, alignment: .trailing) + Spacer(minLength: 4) + Text(formatReset(w.resetAtMs)).font(.caption).foregroundStyle(.secondary) + } else { + Text("no data").font(.caption).foregroundStyle(.secondary) + } + } + } +} + +struct AccountCard: View { + let account: AccountQuota + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Circle().fill(account.isActive ? Color.green : Color.secondary).frame(width: 7, height: 7) + Text(account.name).font(.system(.body, design: .rounded).weight(.medium)) + Spacer() + Text(account.isActive ? "ACTIVE" : "IDLE") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(account.isActive ? .green : .secondary) + } + WindowRow(label: "5h", window: account.fiveHour) + WindowRow(label: "7d", window: account.sevenDay) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(account.isActive ? Color.green.opacity(0.7) : Color.secondary.opacity(0.25), lineWidth: 1)) + } +} + +struct QuotaView: View { + @ObservedObject var model: QuotaModel + @State private var ticker = Date() + private let tick = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(model.accounts) { AccountCard(account: $0) } + Divider() + HStack { + if model.updating { + ProgressView().controlSize(.small) + Text("updating…").font(.caption).foregroundStyle(.secondary) + } else { + Text("Updated \(ageString(model.lastUpdated))").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Refresh") { model.refresh() }.disabled(model.updating) + Button { NSApp.terminate(nil) } label: { Image(systemName: "power") }.buttonStyle(.borderless) + } + } + .padding(12) + .frame(width: 300) + .onAppear { model.refreshIfStale() } + .onReceive(tick) { now in ticker = now; model.loadCache() } + } +} + +@main +struct CodexQuotaApp: App { + @StateObject private var model = QuotaModel() + @State private var titleTick = Date() + private let tick = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + init() { + let m = QuotaModel() + m.loadCache() + _model = StateObject(wrappedValue: m) + } + + var body: some Scene { + MenuBarExtra { + QuotaView(model: model) + } label: { + if let r = model.titleRemaining { + Text("⚡\(r)%") + } else { + Text("⚡?") + } + } + .menuBarExtraStyle(.window) + } +} diff --git a/contrib/macos-app/README.md b/contrib/macos-app/README.md new file mode 100644 index 00000000..b9f4ffb1 --- /dev/null +++ b/contrib/macos-app/README.md @@ -0,0 +1,86 @@ +# Native Menu Bar App (macOS) + +A native SwiftUI menu bar app showing per-account Codex quota from the local +codex-multi-auth cache. It is an alternative to the SwiftBar plugin in +`contrib/swiftbar/` with one key advantage: **it refreshes live while the +panel is open.** + +```text +menu bar: ⚡68% ← active account's 5h-window remaining + +panel (click to open): +┌─────────────────────────────┐ +│ ● account-a ACTIVE │ +│ 5h ▓▓▓▓▓▓▓▓░ 68% 4h 24m │ +│ 7d ▓▓▓▓▓▓▓▓░ 79% 6d 18h │ +├─────────────────────────────┤ +│ ○ account-b IDLE │ +│ 5h ▓▓▓▓▓▓▓▓▓ 90% 4h 14m │ +│ 7d ▓▓▓▓▓▓▓▓▓ 86% 7h 58m │ +├─────────────────────────────┤ +│ updating… [Refresh]│ +└─────────────────────────────┘ +``` + +## Why a native app (vs. the SwiftBar plugin) + +SwiftBar/xbar render the dropdown as an `NSMenu`. macOS does not repaint an +`NSMenu` while it is held open (menu tracking mode), so a probe triggered on +open cannot visibly update the panel — you have to close and reopen to see new +numbers. + +This app uses SwiftUI `MenuBarExtra` with `.menuBarExtraStyle(.window)`, which +renders the panel as a regular window. On open it shows cached values instantly, +kicks off a background `codex-multi-auth check`, and updates the cards **in +place** when the probe returns — no reopen needed. + +## Behavior + +- **Menu bar title**: `⚡%` — the active account's 5h-window remaining + percent (active account resolved from runtime observability, falling back to + the stored active index). Refreshes from cache on a timer. +- **On open**: renders the cache immediately, then runs one live + `codex-multi-auth check` if the cache is older than 60s, updating live. +- **Refresh button**: forces a live check on demand. +- Reading the cache costs no quota; the live check sends one minimal probe per + account. Rows turn orange below 30% remaining and red below 10%. + +## Requirements + +- macOS 13 (Ventura) or newer +- Swift toolchain — Xcode or the Command Line Tools (`xcode-select --install`) +- `codex-multi-auth` on `PATH` + +## Build & install + +```bash +contrib/macos-app/build.sh # compiles and installs ~/Applications/CodexQuota.app +open ~/Applications/CodexQuota.app # launch +``` + +The build shells out to `swiftc` and assembles a minimal `.app` bundle (no +Xcode project). The bundle is ad-hoc signed so Gatekeeper allows the +locally-built binary to run. + +### Autostart at login (optional) + +```bash +sed "s|HOME_PLACEHOLDER|$HOME|" contrib/macos-app/local.codex.quota.plist \ + > ~/Library/LaunchAgents/local.codex.quota.plist +launchctl load ~/Library/LaunchAgents/local.codex.quota.plist +``` + +> If you override `CODEX_MULTI_AUTH_DIR` in your shell profile, note that +> LaunchAgents don't source shell profiles. Uncomment the +> `EnvironmentVariables` block in the plist and set the path explicitly, +> otherwise autostart falls back to `~/.codex/multi-auth`. + +## Notes + +- Data source is the same local files as the SwiftBar plugin: `quota-cache.json`, + `openai-codex-accounts.json`, and `runtime-observability.json` under + `~/.codex/multi-auth/` (honors `CODEX_MULTI_AUTH_DIR`). +- The cache formats are internal to codex-multi-auth and may change between + versions; the app fails soft (`⚡?`) when fields are missing. +- `contrib/` is outside the npm `files` whitelist, so the published package is + unchanged. diff --git a/contrib/macos-app/build.sh b/contrib/macos-app/build.sh new file mode 100755 index 00000000..4907e795 --- /dev/null +++ b/contrib/macos-app/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build CodexQuota.app from CodexQuota.swift and install it to ~/Applications. +# +# Requires the Swift toolchain (Xcode or Command Line Tools) and macOS 13+. +# No Xcode project needed — compiles with swiftc and assembles the bundle by hand. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +APP="${1:-$HOME/Applications/CodexQuota.app}" +BIN="$HERE/.build/CodexQuota" + +echo "Compiling…" +mkdir -p "$HERE/.build" +swiftc -O -parse-as-library "$HERE/CodexQuota.swift" -o "$BIN" + +echo "Assembling bundle at $APP" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +cp "$BIN" "$APP/Contents/MacOS/CodexQuota" +chmod +x "$APP/Contents/MacOS/CodexQuota" + +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleNameCodexQuota + CFBundleDisplayNameCodex Quota + CFBundleIdentifiercom.codexmultiauth.quota + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutableCodexQuota + LSMinimumSystemVersion13.0 + LSUIElement + + +PLIST + +# Ad-hoc signature so Gatekeeper lets the locally-built bundle run. +codesign --force --sign - "$APP" >/dev/null 2>&1 || true + +echo "Done. Launch with: open \"$APP\"" +echo "Autostart at login: copy contrib/macos-app/local.codex.quota.plist into ~/Library/LaunchAgents and run 'launchctl load' it." diff --git a/contrib/macos-app/local.codex.quota.plist b/contrib/macos-app/local.codex.quota.plist new file mode 100644 index 00000000..38c179b4 --- /dev/null +++ b/contrib/macos-app/local.codex.quota.plist @@ -0,0 +1,35 @@ + + + + + + Labellocal.codex.quota + ProgramArguments + + HOME_PLACEHOLDER/Applications/CodexQuota.app/Contents/MacOS/CodexQuota + + + RunAtLoad + KeepAlive + ProcessTypeInteractive + LimitLoadToSessionTypeAqua + +