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
24 changes: 24 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:antigravity-switcher}",
"name": "Debug AntigravityMenuBar",
"target": "AntigravityMenuBar",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug AntigravityMenuBar"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:antigravity-switcher}",
"name": "Release AntigravityMenuBar",
"target": "AntigravityMenuBar",
"configuration": "release",
"preLaunchTask": "swift: Build Release AntigravityMenuBar"
}
]
}
299 changes: 286 additions & 13 deletions Sources/AntigravityMenuBar/AntigravityApp.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import SwiftUI

/// View for menu bar label with real-time countdown
struct MenuBarLabelView: View {
@ObservedObject var menuBarState = MenuBarState.shared

var body: some View {
HStack(spacing: 4) {
Image(systemName: "person.2.circle")
if !menuBarState.countdown.isEmpty {
Text(menuBarState.countdown)
}
}
}
}

@main
struct AntigravityMenuBarApp: App {
@StateObject private var accountManager = AccountManager.shared
@State private var lastError: AppError?
@State private var showErrorAlert = false

var body: some Scene {
MenuBarExtra("Antigravity", systemImage: "person.2.circle") {
MenuBarExtra {
// Header
Text("Antigravity Switcher")
.font(.headline)
Expand All @@ -25,6 +39,25 @@ struct AntigravityMenuBarApp: App {
Button("Switch to this account") {
switchAccount(account)
}

Button("Switch: LIMIT") {
switchAccountLimit(account)
}

// Show Reset if active countdown, otherwise Update Time Limit
if hasActiveCountdown(account) {
Button("Reset") {
accountManager.updateTimeLimit(id: account.id, date: nil)
}
} else {
Button("Update Time Limit") {
showTimeLimitDialog(for: account)
}
}

Button("Check Quota") {
checkQuota(account)
}

Divider()

Expand All @@ -33,19 +66,18 @@ struct AntigravityMenuBarApp: App {
}
} label: {
HStack {
if account.email == "Unknown" {
Image(systemName: "person.circle")
} else {
Image(systemName: "person.fill")
}
VStack(alignment: .leading) {
Text(account.name)
if let email = account.email {
Text(email)
.font(.caption)
.foregroundColor(.secondary)
}
// Show checkmark for active account
if account.email == accountManager.currentEmail {
Image(systemName: "checkmark")
.foregroundColor(.green)
}
// if account.email == "Unknown" {
// Image(systemName: "person.circle")
// } else {
// Image(systemName: "person.fill")
// }
Comment on lines +74 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove commented-out code.

Dead code should be removed to maintain code cleanliness. If this logic is needed later, it can be retrieved from version control.

                             Image(systemName: "checkmark")
                                 .foregroundColor(.green)
                         }
-                            // if account.email == "Unknown" {
-                            //     Image(systemName: "person.circle")
-                            // } else {
-                            //     Image(systemName: "person.fill")
-                            // }
                             // Build display name with countdown if applicable
                             Text(accountDisplayName(account))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// if account.email == "Unknown" {
// Image(systemName: "person.circle")
// } else {
// Image(systemName: "person.fill")
// }
Image(systemName: "checkmark")
.foregroundColor(.green)
}
// Build display name with countdown if applicable
Text(accountDisplayName(account))
🤖 Prompt for AI Agents
In Sources/AntigravityMenuBar/AntigravityApp.swift around lines 66 to 70, remove
the commented-out conditional block that toggles Image(systemName:
"person.circle") vs "person.fill" as it is dead code; delete those commented
lines so the file contains only active code and rely on version control to
recover this logic if needed later.

// Build display name with countdown if applicable
Text(accountDisplayName(account))
}
}
}
Expand All @@ -65,6 +97,8 @@ struct AntigravityMenuBarApp: App {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
} label: {
MenuBarLabelView()
}
.menuBarExtraStyle(.menu) // Dropdown menu style
// Note: Alerts in MenuBarExtra are tricky. Standard SwiftUI .alert might not show up over a menu bar app easily.
Expand All @@ -85,6 +119,18 @@ struct AntigravityMenuBarApp: App {
}
}
}

private func switchAccountLimit(_ account: Account) {
Task {
do {
try await accountManager.switchAccountApplyingLimitToCurrent(id: account.id)
} catch let error as AppError {
showError(error)
} catch {
print("Unexpected error: \(error)")
}
}
}

private func backupCurrent() {
Task {
Expand All @@ -98,17 +144,244 @@ struct AntigravityMenuBarApp: App {
}
}

private func accountDisplayName(_ account: Account) -> String {
var displayName = account.name

// Append countdown if time_limit is set and not expired
if let timeLimitStr = account.time_limit,
let timeLimit = ISO8601DateFormatter().date(from: timeLimitStr),
let countdown = TimeLimitFormatter.formatCountdown(to: timeLimit) {
displayName += " \(countdown)"
}

return displayName
}

private func hasActiveCountdown(_ account: Account) -> Bool {
guard let timeLimitStr = account.time_limit,
let timeLimit = ISO8601DateFormatter().date(from: timeLimitStr) else {
return false
}
return timeLimit > Date() // Active if not expired
}
Comment on lines +147 to +166
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Cache ISO8601DateFormatter and consider extracting to a shared helper.

Both accountDisplayName and hasActiveCountdown create new ISO8601DateFormatter() instances on each call. This duplicates logic already present in AccountManager.updateMenuBarCountdown. Consider:

  1. Caching the formatter as a static property
  2. Adding a helper method on Account or in TimeLimitFormatter to parse the time_limit field

You could add a computed property to Account:

extension Account {
    var timeLimitDate: Date? {
        guard let timeLimitStr = time_limit else { return nil }
        return ISO8601DateFormatter().date(from: timeLimitStr)
    }
}

Or better, add a cached formatter in TimeLimitFormatter:

static func parseISO8601(_ string: String) -> Date? {
    // Use a cached ISO8601DateFormatter
}
🤖 Prompt for AI Agents
In Sources/AntigravityMenuBar/AntigravityApp.swift around lines 127 to 146, both
accountDisplayName(_) and hasActiveCountdown(_) create new ISO8601DateFormatter
instances on each call; change this to use a single cached formatter and a
parsing helper: add a shared ISO8601DateFormatter (preferably as a static on
TimeLimitFormatter) and a helper function (e.g.,
TimeLimitFormatter.parseISO8601(_:)) or a computed property on Account
(timeLimitDate) that uses the cached formatter, then replace the inline
ISO8601DateFormatter().date(from:) calls with the new helper so parsing is
centralized and allocations are avoided.


private func removeAccount(_ account: Account) {
do {
try accountManager.removeAccount(id: account.id)
} catch {
print("Error removing account: \(error)")
}
}

private func checkQuota(_ account: Account, forceRefresh: Bool = false) {
Task {
do {
if !forceRefresh, let cached = accountManager.cachedQuota(id: account.id) {
await MainActor.run {
showQuotaDialog(snapshot: cached, account: account, isCached: true)
}
return
}

let proceed = await MainActor.run {
confirmQuotaCheck(for: account)
}
guard proceed else { return }

let panel = await MainActor.run { () -> ProgressPanel in
let p = ProgressPanel(title: "Checking Quota", message: "Fetching quota data…")
p.show()
return p
}
defer {
Task { @MainActor in
panel.close()
}
}
Comment on lines +196 to +200
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

defer may not execute as expected in async context.

The defer block captures panel and schedules a Task to close it. However, if an exception is thrown, the defer runs synchronously on the current actor context, but the Task { @MainActor in ... } schedules work asynchronously. This should work, but consider using a simpler pattern:

-                defer {
-                    Task { @MainActor in
-                        panel.close()
-                    }
-                }
-
-                let snapshot = try await accountManager.checkQuota(id: account.id)
+                let snapshot: QuotaSnapshot
+                do {
+                    snapshot = try await accountManager.checkQuota(id: account.id)
+                } catch {
+                    await MainActor.run { panel.close() }
+                    throw error
+                }
+                await MainActor.run { panel.close() }

Or wrap the entire operation in a do-catch that ensures panel.close() is called synchronously on the main actor.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In Sources/AntigravityMenuBar/AntigravityApp.swift around lines 196–200, the
defer currently schedules panel.close() inside Task { @MainActor in ... } which
runs asynchronously and can skip guaranteed synchronous cleanup; replace this
pattern by wrapping the async work in a do-catch (or try/await) and after the
operation (in the finally-equivalent path) call MainActor.run to invoke
panel.close() on the main actor (i.e., perform await MainActor.run {
panel.close() }) so the panel is closed deterministically on the main actor even
if errors occur.


let snapshot = try await accountManager.checkQuota(id: account.id)

await MainActor.run {
showQuotaDialog(snapshot: snapshot, account: account, isCached: false)
}
} catch let error as AppError {
await MainActor.run {
showError(error)
}
} catch {
print("Unexpected error: \(error)")
}
}
}

@MainActor
private func showError(_ error: AppError) {
let alert = NSAlert(error: error)
alert.runModal()
}

@MainActor
private func showQuotaDialog(snapshot: QuotaSnapshot, account: Account, isCached: Bool) {
let alert = NSAlert()
alert.messageText = "Quota – \(account.name)"
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Refresh")

let headerLines: [String] = [
snapshot.userEmail.map { "Email: \($0)" } ?? "Email: (unknown)",
snapshot.planName.map { "Plan: \($0)" } ?? "Plan: (unknown)",
snapshot.teamsTier.map { "Tier: \($0)" } ?? "Tier: (unknown)",
"Fetched at: \(snapshot.fetchedAt.formatted(date: .abbreviated, time: .standard))" + (isCached ? " (cached)" : "")
]

var lines: [String] = headerLines

if let pc = snapshot.promptCredits {
lines.append("")
lines.append(String(format: "Prompt credits: %.0f / %.0f (%.1f%%)", pc.available, pc.monthly, pc.remainingPercentage))
}

let models = snapshot.models.sorted { (a, b) in
let ap = a.remainingPercentage ?? 101
let bp = b.remainingPercentage ?? 101
if ap == bp { return a.label < b.label }
return ap < bp
}

if !models.isEmpty {
lines.append("")
lines.append("Models:")

for m in models {
let pct = m.remainingPercentage.map { String(format: "%.1f%%", $0) } ?? "N/A"
let reset = m.resetTime.map { formatTimeUntil($0) } ?? "N/A"
let exhausted = m.isExhausted ? " (exhausted)" : ""
lines.append("- \(m.label): \(pct), resets in: \(reset)\(exhausted)")
}
} else {
lines.append("")
lines.append("No model quota data returned.")
}

let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
textView.isEditable = false
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.string = lines.joined(separator: "\n")

let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false

alert.accessoryView = scrollView
Comment on lines +267 to +278
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider setting textView.maxSize for proper scroll behavior.

The NSTextView may not scroll correctly without setting its max size to allow vertical growth:

         let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
         textView.isEditable = false
         textView.isSelectable = true
         textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
         textView.string = lines.joined(separator: "\n")
+        textView.isVerticallyResizable = true
+        textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
+        textView.textContainer?.widthTracksTextView = true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
textView.isEditable = false
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.string = lines.joined(separator: "\n")
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
alert.accessoryView = scrollView
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
textView.isEditable = false
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.string = lines.joined(separator: "\n")
textView.isVerticallyResizable = true
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 520, height: 320))
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
alert.accessoryView = scrollView
🤖 Prompt for AI Agents
In Sources/AntigravityMenuBar/AntigravityApp.swift around lines 267–278, the
NSTextView used as the NSScrollView's documentView needs a proper maxSize and
resizing behavior so vertical scrolling works correctly; set the
textView.maxSize to a very large size (e.g. greatestFiniteMagnitude), enable
vertical resizing (textView.isVerticallyResizable = true), ensure the text
container does not constrain height
(textView.textContainer?.heightTracksTextView = false) and let the container
track width (textView.textContainer?.widthTracksTextView = true) before
assigning it to scrollView.documentView.

let response = alert.runModal()
if response == .alertSecondButtonReturn {
checkQuota(account, forceRefresh: true)
}
}

@MainActor
private func confirmQuotaCheck(for account: Account) -> Bool {
let alert = NSAlert()
alert.messageText = "Check Quota"

let active = account.email != nil && account.email == accountManager.currentEmail
if active {
alert.informativeText = "This will fetch quota for the currently active Antigravity account."
} else {
alert.informativeText = "This may temporarily switch Antigravity to this account and restart Antigravity to fetch quota. Continue?"
}

alert.alertStyle = .informational
alert.addButton(withTitle: "Continue")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}

private func formatTimeUntil(_ date: Date) -> String {
let diff = date.timeIntervalSinceNow
if diff <= 0 { return "Ready" }
let mins = Int(ceil(diff / 60))
if mins < 60 { return "\(mins)m" }
let hours = mins / 60
let rem = mins % 60
return "\(hours)h \(rem)m"
}

@MainActor
private func showTimeLimitDialog(for account: Account) {
let alert = NSAlert()
alert.messageText = "Update Time Limit"
alert.informativeText = "Enter time (HH:mm) and date (dd.MM.yyyy)\nLeave empty to clear time limit."
alert.alertStyle = .informational
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")

// Create stack view for inputs
let stackView = NSStackView(frame: NSRect(x: 0, y: 0, width: 260, height: 60))
stackView.orientation = .vertical
stackView.spacing = 8

// Time input row
let timeRow = NSStackView()
timeRow.orientation = .horizontal
timeRow.spacing = 8

let timeLabel = NSTextField(labelWithString: "Time:")
timeLabel.frame = NSRect(x: 0, y: 0, width: 50, height: 22)

let timeField = NSTextField(frame: NSRect(x: 0, y: 0, width: 80, height: 22))
timeField.placeholderString = "HH:mm"
timeField.stringValue = TimeLimitFormatter.defaultTimeString()

timeRow.addArrangedSubview(timeLabel)
timeRow.addArrangedSubview(timeField)

// Date input row
let dateRow = NSStackView()
dateRow.orientation = .horizontal
dateRow.spacing = 8

let dateLabel = NSTextField(labelWithString: "Date:")
dateLabel.frame = NSRect(x: 0, y: 0, width: 50, height: 22)

let dateField = NSTextField(frame: NSRect(x: 0, y: 0, width: 120, height: 22))
dateField.placeholderString = "dd.MM.yyyy"
dateField.stringValue = TimeLimitFormatter.defaultDateString()

dateRow.addArrangedSubview(dateLabel)
dateRow.addArrangedSubview(dateField)

stackView.addArrangedSubview(timeRow)
stackView.addArrangedSubview(dateRow)

alert.accessoryView = stackView

let response = alert.runModal()

if response == .alertFirstButtonReturn {
let timeValue = timeField.stringValue.trimmingCharacters(in: .whitespaces)
let dateValue = dateField.stringValue.trimmingCharacters(in: .whitespaces)

// Clear time limit if both empty
if timeValue.isEmpty && dateValue.isEmpty {
accountManager.updateTimeLimit(id: account.id, date: nil)
return
}

// Validate and parse
guard let parsedDate = TimeLimitFormatter.parse(time: timeValue, date: dateValue) else {
let errorAlert = NSAlert()
errorAlert.messageText = "Invalid Format"
errorAlert.informativeText = "Please enter time as HH:mm (e.g., 14:30) and date as dd.MM.yyyy (e.g., 31.12.2026)"
errorAlert.alertStyle = .warning
errorAlert.runModal()
return
}
Comment on lines +374 to +382
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider validating that the time limit is in the future.

The code validates the format but doesn't check if the parsed date is in the future. Setting a time limit in the past would be immediately expired and have no effect.

             // Validate and parse
             guard let parsedDate = TimeLimitFormatter.parse(time: timeValue, date: dateValue) else {
                 let errorAlert = NSAlert()
                 errorAlert.messageText = "Invalid Format"
                 errorAlert.informativeText = "Please enter time as HH:mm (e.g., 14:30) and date as dd.MM.yyyy (e.g., 31.12.2026)"
                 errorAlert.alertStyle = .warning
                 errorAlert.runModal()
                 return
             }
             
+            guard parsedDate > Date() else {
+                let errorAlert = NSAlert()
+                errorAlert.messageText = "Invalid Date"
+                errorAlert.informativeText = "The time limit must be in the future."
+                errorAlert.alertStyle = .warning
+                errorAlert.runModal()
+                return
+            }
+            
             accountManager.updateTimeLimit(id: account.id, date: parsedDate)
🤖 Prompt for AI Agents
In Sources/AntigravityMenuBar/AntigravityApp.swift around lines 223 to 231,
after parsing the date/time you must verify the resulting parsedDate is in the
future; if parsedDate <= Date() show an NSAlert with a clear message (e.g.,
"Time limit must be in the future") and return. Implement the comparison using
Date() (or Calendar.current.compare) to avoid timezone issues and keep the
existing UI flow (alertStyle .warning and runModal()) before returning.


accountManager.updateTimeLimit(id: account.id, date: parsedDate)
}
}
}
Loading