Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contrib/macos-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.build/
254 changes: 254 additions & 0 deletions contrib/macos-app/CodexQuota.swift
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") ?? [:]
Comment on lines +39 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

clear published state when the backing files disappear or go empty.

line 40 returns without resetting anything, and line 80 never clears lastUpdated when the new snapshot has no timestamps. after logout, storage repair, or a CODEX_MULTI_AUTH_DIR switch, the window can keep showing the previous account cards and stale freshness text even though the source files are gone. clear accounts and lastUpdated on load failure, and nil out lastUpdated when newest == 0. please add a regression test that deletes or corrupts openai-codex-accounts.json after a successful load. the cache here is a derived local artifact, so stale ui state is worse than showing nothing. see lib/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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contrib/macos-app/CodexQuota.swift` around lines 39 - 41, The loadCache()
path must clear published state when backing files disappear or are empty: when
json("openai-codex-accounts.json") returns nil or an empty structure, set
accounts = [:] (or the empty collection type used) and lastUpdated = nil instead
of returning early; when computing newest from the quota snapshot (the logic
around newest and the cache handling near the existing newest == 0 branch), set
lastUpdated = nil whenever newest == 0; update any cache assignment logic for
json("quota-cache.json") to tolerate missing/empty caches but not preserve prior
published state. Add a unit/integration regression test that loads successfully,
then deletes or corrupts openai-codex-accounts.json (and/or switches
CODEX_MULTI_AUTH_DIR) and asserts accounts is empty and lastUpdated is nil after
calling loadCache().

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

mirror the cache's email fallback when resolving quota entries.

line 68 only checks byAccountId. the persisted quota cache also carries byEmail, and the main cli display path resolves through that fallback before rendering quota state. with the current lookup, migrated or re-keyed accounts can render no data here even though the canonical ui still has quota for the same account. please add a regression test for a cache file that only matches on normalized email. see lib/quota-cache.ts:9-36 and lib/codex-manager/login-menu-data.ts:430-468.

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, email dedup is case-insensitive via normalizeEmailKey() (trim + lowercase).

🧰 Tools
🪛 SwiftLint (0.63.3)

[Warning] 55-55: Prefer empty collection over optional collection

(discouraged_optional_collection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contrib/macos-app/CodexQuota.swift` around lines 41 - 75, The quota lookup
currently only checks the cache map byId (byAccountId) and misses entries stored
under the cache's byEmail key; update the construction around the for-loop
(where byId is read and each entry is retrieved into entry) to first attempt
lookup from byId[id], then if nil attempt lookup from the cache's byEmail using
the normalized email key (use normalizeEmailKey(email) to match CLI behavior) so
migrated/re-keyed accounts find their quota; keep using the existing
window(_:,_) and AccountQuota(...) flow and add a regression test that loads a
quota-cache.json containing only byEmail entries (with mixed-case emails) to
assert quota is found for the account after normalization.

Source: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
try? task.run()
task.waitUntilExit()
guard (try? task.run()) != nil else {
DispatchQueue.main.async { self.updating = false }
return
}
task.waitUntilExit()
Prompt To Fix With AI
This 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()
}
Comment thread
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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)
}
}
86 changes: 86 additions & 0 deletions contrib/macos-app/README.md
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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 codex-multi-auth must be "on PATH", but contrib/macos-app/CodexQuota.swift:95 hardcodes PATH=/usr/local/bin:/opt/homebrew/bin:$HOME/.npm-global/bin:$PATH. if a user installed codex-multi-auth to a custom prefix (e.g., ~/.local/bin or a non-standard npm prefix), the subprocess won't find it even though their interactive shell PATH includes that location.

recommend either:

  • document the explicit PATH construction here so users know which install paths are supported, or
  • note that the app uses a fixed PATH and may not honor all shell configurations.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contrib/macos-app/README.md` at line 52, The README claim that
`codex-multi-auth` just needs to be "on PATH" is misleading because
contrib/macos-app/CodexQuota.swift hardcodes PATH at the top of the spawned
process to "/usr/local/bin:/opt/homebrew/bin:$HOME/.npm-global/bin:$PATH", so
installs in custom prefixes (e.g., ~/.local/bin) will be ignored; fix by either
(A) updating the README to explicitly state the app uses that fixed PATH and
list the exact directories included (referencing CodexQuota.swift and the
hardcoded PATH string) or (B) change the app to respect the user’s environment
PATH by removing the hardcoded PATH override in CodexQuota.swift (or
appending/prepending additional common prefixes like ~/.local/bin) so
subprocesses can find user-installed `codex-multi-auth`.


## Build & install

```bash
contrib/macos-app/build.sh # compiles and installs ~/Applications/CodexQuota.app

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ~/Applications/CodexQuota.app, but contrib/macos-app/build.sh:6 accepts an optional first argument to override the install location (APP="${1:-$HOME/Applications/CodexQuota.app}"). consider mentioning this for users who want a different install path:

contrib/macos-app/build.sh /Applications/CodexQuota.app  # install system-wide
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contrib/macos-app/README.md` at line 57, Update the README entry for
contrib/macos-app/build.sh to mention that build.sh accepts an optional install
path override via its first argument (see
APP="${1:-$HOME/Applications/CodexQuota.app}" in build.sh), and include an
example showing how to pass a custom path (e.g., using
/Applications/CodexQuota.app for system-wide install) so users know they can
override the default ~/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.
44 changes: 44 additions & 0 deletions contrib/macos-app/build.sh
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
swiftc -O -parse-as-library "$HERE/CodexQuota.swift" -o "$BIN"
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"
Prompt To Fix With AI
This 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

add a safety guard before recursive delete.

line 17 runs rm -rf on a user-provided path from line 9. this can delete unintended directories if the arg is malformed or mis-passed.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contrib/macos-app/build.sh` at line 17, Add a safety guard before the rm -rf
"$APP" call: validate the APP variable (set earlier) is non-empty and not a
dangerous path ("/", "", ".", ".."), and optionally ensure it is inside the
project/build workspace (e.g., starts with "$PWD" or a known build dir) before
running rm -rf; if the check fails, print an error and exit non-zero. Use the
APP variable name and the rm -rf invocation to locate where to insert this
validation in build.sh.

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."
Loading