-
Notifications
You must be signed in to change notification settings - Fork 33
feat(contrib): native SwiftUI menu bar app for account quota (macOS) #605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| .build/ |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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") | ||||||||||||||||
|
Comment on lines
+41
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mirror the cache's email fallback when resolving quota entries. line 68 only checks proposed fix let cache = json("quota-cache.json") ?? [:]
let observ = json("runtime-observability.json") ?? [:]
let byId = cache["byAccountId"] as? [String: Any] ?? [:]
+ let byEmail = cache["byEmail"] as? [String: Any] ?? [:]
+
+ func normalizeEmailKey(_ email: String) -> String {
+ email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ }
@@
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]
+ let entry =
+ (byId[id] as? [String: Any]) ??
+ (byEmail[normalizeEmailKey(email)] as? [String: Any])
if let u = (entry?["updatedAt"] as? NSNumber)?.doubleValue { newest = max(newest, u) }as per coding guidelines, 🧰 Tools🪛 SwiftLint (0.63.3)[Warning] 55-55: Prefer empty collection over optional collection (discouraged_optional_collection) 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||
| )) | ||||||||||||||||
| } | ||||||||||||||||
| 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() | ||||||||||||||||
|
Comment on lines
+98
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: contrib/macos-app/CodexQuota.swift
Line: 98-99
Comment:
`try? task.run()` silently discards any launch error and then falls through to `task.waitUntilExit()` unconditionally. If `run()` throws (e.g., sandbox denial, unexpected fs state), `waitUntilExit()` on an un-started `Process` hits a `preconditionFailure("task not launched")` and crashes the app. Guard the wait behind a successful launch.
```suggestion
guard (try? task.run()) != nil else {
DispatchQueue.main.async { self.updating = false }
return
}
task.waitUntilExit()
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
| 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() | ||||||||||||||||
| } | ||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||
|
|
||||||||||||||||
| 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() | ||||||||||||||||
|
Comment on lines
+233
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: contrib/macos-app/CodexQuota.swift
Line: 233-234
Comment:
**Dead timer — menu bar title never auto-refreshes while panel is closed**
`tick` is declared and `autoconnect()`ed, but `App.body` returns a `Scene` — you can't attach `.onReceive` to a `Scene`, and there's no such call anywhere in this file. `titleTick` is assigned nowhere in `body`. As a result, `model.loadCache()` is never called from this timer; `model.accounts` (which drives `model.titleRemaining`) only changes when the panel is opened or a refresh completes. The README says the title "refreshes from cache on a timer," but with the panel closed the percentage is frozen at whatever it was at startup/last panel close — a user glancing at the icon after an hour sees stale data. To fix, drive the periodic reload from inside `QuotaModel` (e.g., schedule an `NSTimer` in `init`) so it isn't tied to a view's `onReceive`.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
|
|
||||||||||||||||
| 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) | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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**: `⚡<n>%` — 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` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win path requirement is narrower than stated. line 52 says recommend either:
🤖 Prompt for AI Agents |
||
|
|
||
| ## Build & install | ||
|
|
||
| ```bash | ||
| contrib/macos-app/build.sh # compiles and installs ~/Applications/CodexQuota.app | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | 💤 Low value build.sh accepts an optional install path override. line 57 shows the default contrib/macos-app/build.sh /Applications/CodexQuota.app # install system-wide🤖 Prompt for AI Agents |
||
| 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. | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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" | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: contrib/macos-app/build.sh
Line: 14
Comment:
`swiftc` without an explicit `-sdk` or `-target` compiles against whatever the toolchain selects by default. on machines with both Xcode and Command Line Tools pointing at different SDK roots, this can link against an unexpected SDK version or silently pick the wrong target architecture (x86_64 vs arm64). passing `$(xcrun --show-sdk-path)` and an explicit `-target` makes the build deterministic across setups.
```suggestion
SDK=$(xcrun --show-sdk-path)
ARCH=$(uname -m)
swiftc -O -parse-as-library -sdk "$SDK" -target "${ARCH}-apple-macos13.0" "$HERE/CodexQuota.swift" -o "$BIN"
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||
|
|
||||||||||
| echo "Assembling bundle at $APP" | ||||||||||
| rm -rf "$APP" | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add a safety guard before recursive delete. line 17 runs proposed fix APP="${1:-$HOME/Applications/CodexQuota.app}"
BIN="$HERE/.build/CodexQuota"
+if [[ -z "${APP:-}" || "$APP" == "/" || "$APP" == "$HOME" || "$APP" != *.app ]]; then
+ echo "refusing unsafe app target: $APP" >&2
+ exit 1
+fi
+
echo "Compiling…"
mkdir -p "$HERE/.build"
swiftc -O -parse-as-library "$HERE/CodexQuota.swift" -o "$BIN"🤖 Prompt for AI Agents |
||||||||||
| mkdir -p "$APP/Contents/MacOS" | ||||||||||
| cp "$BIN" "$APP/Contents/MacOS/CodexQuota" | ||||||||||
| chmod +x "$APP/Contents/MacOS/CodexQuota" | ||||||||||
|
|
||||||||||
| cat > "$APP/Contents/Info.plist" <<'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"> | ||||||||||
| <plist version="1.0"> | ||||||||||
| <dict> | ||||||||||
| <key>CFBundleName</key><string>CodexQuota</string> | ||||||||||
| <key>CFBundleDisplayName</key><string>Codex Quota</string> | ||||||||||
| <key>CFBundleIdentifier</key><string>com.codexmultiauth.quota</string> | ||||||||||
| <key>CFBundleVersion</key><string>1.0</string> | ||||||||||
| <key>CFBundleShortVersionString</key><string>1.0</string> | ||||||||||
| <key>CFBundlePackageType</key><string>APPL</string> | ||||||||||
| <key>CFBundleExecutable</key><string>CodexQuota</string> | ||||||||||
| <key>LSMinimumSystemVersion</key><string>13.0</string> | ||||||||||
| <key>LSUIElement</key><true/> | ||||||||||
| </dict> | ||||||||||
| </plist> | ||||||||||
| 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." | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
clear published state when the backing files disappear or go empty.
line 40 returns without resetting anything, and line 80 never clears
lastUpdatedwhen the new snapshot has no timestamps. after logout, storage repair, or aCODEX_MULTI_AUTH_DIRswitch, the window can keep showing the previous account cards and stale freshness text even though the source files are gone. clearaccountsandlastUpdatedon load failure, and nil outlastUpdatedwhennewest == 0. please add a regression test that deletes or corruptsopenai-codex-accounts.jsonafter a successful load. the cache here is a derived local artifact, so stale ui state is worse than showing nothing. seelib/quota-cache.ts:9-36.proposed fix
/// Read the three local files and rebuild `accounts`. No network, no probe. func loadCache() { - guard let store = json("openai-codex-accounts.json") else { return } + guard let store = json("openai-codex-accounts.json") else { + DispatchQueue.main.async { + self.accounts = [] + self.lastUpdated = nil + } + return + } @@ DispatchQueue.main.async { self.accounts = result - if newest > 0 { self.lastUpdated = Date(timeIntervalSince1970: newest / 1000) } + self.lastUpdated = newest > 0 + ? Date(timeIntervalSince1970: newest / 1000) + : nil } }Also applies to: 78-80
🤖 Prompt for AI Agents