Files
steipete--codexbar/Sources/CodexBar/StatusItemController+Actions.swift
T
2026-07-13 12:22:33 +08:00

882 lines
34 KiB
Swift

import AppKit
import CodexBarCore
extension StatusItemController {
/// Identifies which manual refresh a task belongs to, so per-provider refreshes stay independent
/// of each other and of the all-providers refresh.
enum ManualRefreshScope: Hashable {
case global
case provider(UsageProvider)
}
}
enum LoginNotificationLogic {
static func notificationCopy(providerName: String) -> (title: String, body: String) {
(
L("login_success_notification_title", providerName),
L("login_success_notification_body"))
}
}
extension StatusItemController: StatusItemMenuPersistentActionDelegate {
// MARK: - Actions reachable from menus
func refreshStore(
forceTokenUsage: Bool,
refreshOpenMenusWhenComplete: Bool = true,
interaction: ProviderInteraction = .userInitiated)
{
Task {
await self.performStoreRefresh(
forceTokenUsage: forceTokenUsage,
refreshOpenMenusWhenComplete: refreshOpenMenusWhenComplete,
interaction: interaction)
}
}
func performStoreRefresh(
forceTokenUsage: Bool,
refreshOpenMenusWhenComplete: Bool,
interaction: ProviderInteraction) async
{
await self.withProviderInteraction(interaction) {
await self.store.refresh(forceTokenUsage: forceTokenUsage)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
self.store.scheduleStorageFootprintRefreshForOverview(force: true)
if refreshOpenMenusWhenComplete {
self.refreshOpenMenusAfterExplicitStoreAction()
} else {
self.invalidateMenus()
}
}
}
func performStoreRefresh(
enrichmentMode: UsageStore.RefreshEnrichmentMode,
refreshOpenMenusWhenComplete: Bool,
interaction: ProviderInteraction) async
{
await self.withProviderInteraction(interaction) {
await self.store.refresh(enrichmentMode: enrichmentMode)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
self.store.scheduleStorageFootprintRefreshForOverview(force: true)
if refreshOpenMenusWhenComplete {
self.refreshOpenMenusAfterExplicitStoreAction()
} else {
self.invalidateMenus()
}
}
}
func performStoreRefresh(
for provider: UsageProvider,
refreshOpenMenusWhenComplete: Bool,
interaction: ProviderInteraction) async
{
await self.withProviderInteraction(interaction) {
await self.store.awaitForcedRefreshEnrichment()
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
let refreshStartedAt = Date()
await self.store.refreshProvider(provider)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
await self.store.refreshProviderStatus(provider)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
await self.store.refreshTokenUsageNow(for: provider, force: true)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
if provider == .codex {
await self.store.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
await self.store.refreshOpenAIDashboardIfNeeded(
force: true,
expectedGuard: self.store.freshCodexOpenAIWebRefreshGuard(),
bypassCoalescing: true)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
if self.store.openAIDashboardRequiresLogin {
await self.store.refreshProvider(.codex)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
await self.store.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt)
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
}
}
self.store.scheduleStorageFootprintRefresh(for: [provider], force: true)
self.store.persistWidgetSnapshot(reason: "provider-refresh")
if refreshOpenMenusWhenComplete {
self.refreshOpenMenusAfterExplicitStoreAction()
} else {
self.invalidateMenus()
}
}
}
private func withProviderInteraction(
_ interaction: ProviderInteraction,
operation: () async -> Void) async
{
if interaction == .userInitiated {
await BrowserCookieAccessGate.withExplicitRetry {
await ProviderInteractionContext.$current.withValue(interaction) {
await operation()
}
}
} else {
await ProviderInteractionContext.$current.withValue(interaction) {
await operation()
}
}
}
func refreshOpenMenusAfterExplicitStoreAction() {
self.invalidateMenus(
refreshOpenMenus: true,
deferOpenParentMenuRebuild: true)
}
@objc func refreshNow() {
self.startManualRefresh(
for: nil,
originatingMenuID: nil,
originatingMenuInteractionGeneration: nil)
}
@objc func refreshMenuItem(_ sender: NSMenuItem) {
self.refreshMenuProviderNow(in: sender.menu)
}
func refreshMenuProviderNow(in menu: NSMenu?) {
let originatingMenuID = menu.map(ObjectIdentifier.init)
let originatingMenuInteractionGeneration = originatingMenuID.flatMap {
self.menuSession.menuInteractionGeneration(for: $0)
}
self.startManualRefresh(
for: self.manualRefreshProvider(for: menu),
originatingMenuID: originatingMenuID,
originatingMenuInteractionGeneration: originatingMenuInteractionGeneration)
}
private func refreshMenuProviderNow(
menuID: ObjectIdentifier,
originatingMenuInteractionGeneration: Int)
{
let menu = self.openMenus[menuID] ?? self.mergedMenu.flatMap {
ObjectIdentifier($0) == menuID ? $0 : nil
}
let provider = menu.flatMap { self.manualRefreshProvider(for: $0) } ?? self.menuProviders[menuID]
self.startManualRefresh(
for: provider,
originatingMenuID: menuID,
originatingMenuInteractionGeneration: originatingMenuInteractionGeneration)
}
private func startManualRefresh(
for provider: UsageProvider?,
originatingMenuID: ObjectIdentifier?,
originatingMenuInteractionGeneration: Int?)
{
let scope: ManualRefreshScope = provider.map(ManualRefreshScope.provider) ?? .global
let scopedRefreshInFlight = provider.map { self.store.refreshingProviders.contains($0) }
?? !self.store.refreshingProviders.isEmpty
// Two different providers may refresh concurrently, but an all-providers (.global) refresh must
// not overlap a per-provider one (or vice versa) — that would duplicate the shared fetch work.
let conflictsWithOtherScope = scope == .global
? self.manualRefreshTasks.contains { $0.key != .global }
: self.manualRefreshTasks[.global] != nil
guard !self.hasPreparedForAppShutdown,
self.manualRefreshTasks[scope] == nil,
!conflictsWithOtherScope,
!self.store.hasForcedRefreshEnrichmentInFlight,
!self.store.isRefreshing,
!scopedRefreshInFlight
else { return }
let frozenModels = self.frozenManualRefreshMenuCardModels()
let viewportRestoreRequests = self.armManualRefreshViewportRestoreRequests(
originatingMenuID: originatingMenuID,
originatingMenuInteractionGeneration: originatingMenuInteractionGeneration)
let task = Task { @MainActor [weak self] in
guard let self else { return }
var completed = false
defer {
self.manualRefreshTasks[scope] = nil
self.menuCardRefreshMonitor.endManualRefresh(for: provider)
self.updatePersistentRefreshItemsEnabled()
if completed {
self.scheduleCompletedManualRefreshViewportRestore(viewportRestoreRequests)
} else {
self.cancelManualRefreshViewportRestoreRequests(viewportRestoreRequests)
}
self.completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded()
self.prepareAttachedClosedMenusIfNeeded()
}
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
#if DEBUG
if let operation = self._test_manualRefreshOperation {
await operation()
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
completed = true
return
}
#endif
if let provider {
await self.performStoreRefresh(
for: provider,
refreshOpenMenusWhenComplete: true,
interaction: .userInitiated)
} else {
await self.performStoreRefresh(
enrichmentMode: .forcedBackground,
refreshOpenMenusWhenComplete: true,
interaction: .userInitiated)
}
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
completed = true
}
self.manualRefreshTasks[scope] = task
self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider)
self.updatePersistentRefreshItemsEnabled()
}
private func manualRefreshProvider(for menu: NSMenu?) -> UsageProvider? {
guard let menu else { return nil }
if self.shouldMergeIcons {
guard self.mergedMenu == nil || menu === self.mergedMenu else { return nil }
guard !self.isMergedOverviewSelected(in: menu) else { return nil }
return self.resolvedMenuProvider()
}
return self.menuProviders[ObjectIdentifier(menu)]
}
private func frozenManualRefreshMenuCardModels() -> [UsageProvider: UsageMenuCardView.Model] {
var providers = self.store.enabledProvidersForDisplay()
if let lastMenuProvider,
!providers.contains(lastMenuProvider)
{
providers.append(lastMenuProvider)
}
if providers.isEmpty,
let defaultProvider = self.settings.orderedProviders().first ?? UsageProvider.allCases.first
{
providers.append(defaultProvider)
}
var models: [UsageProvider: UsageMenuCardView.Model] = [:]
for provider in providers {
models[provider] = self.menuCardModel(for: provider)
}
return models
}
func performPersistentRefreshAction(in menuID: ObjectIdentifier) {
guard let menuInteractionGeneration = self.menuSession.menuInteractionGeneration(for: menuID) else { return }
self.performPersistentRefreshAction(
in: menuID,
menuInteractionGeneration: menuInteractionGeneration)
}
nonisolated func performPersistentRefreshAction(
in menuID: ObjectIdentifier,
menuInteractionGeneration: Int)
{
Task { @MainActor [weak self] in
guard let self else { return }
self.refreshMenuProviderNow(
menuID: menuID,
originatingMenuInteractionGeneration: menuInteractionGeneration)
}
}
nonisolated func performPersistentSettingsAction() {
Task { @MainActor [weak self] in
guard let self else { return }
self.closeOpenMenusFromShortcutIfNeeded()
self.showSettingsGeneral()
}
}
nonisolated func performPersistentQuitAction() {
Task { @MainActor [weak self] in
guard let self else { return }
self.closeOpenMenusFromShortcutIfNeeded()
self.quit()
}
}
nonisolated func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) {
Task { @MainActor [weak self] in
self?.navigateProviderSwitcher(direction)
}
}
@objc func refreshAugmentSession() {
Task {
await self.store.forceRefreshAugmentSession()
// Also trigger a full refresh to update the menu and clear any stale errors
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refresh(forceTokenUsage: false)
}
self.refreshOpenMenusAfterExplicitStoreAction()
}
}
@objc func installUpdate() {
self.updater.installUpdate()
}
@objc func openDashboard() {
let preferred = self.lastMenuProvider
?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first)
let provider = preferred ?? .codex
guard let url = self.dashboardURL(for: provider) else { return }
NSWorkspace.shared.open(url)
}
func dashboardURL(
for provider: UsageProvider,
environment: [String: String] = ProcessInfo.processInfo.environment) -> URL?
{
if provider == .alibaba {
return self.settings.alibabaCodingPlanAPIRegion.dashboardURL
}
if provider == .alibabatokenplan {
return AlibabaTokenPlanUsageFetcher.dashboardURL(
region: self.settings.alibabaTokenPlanAPIRegion,
environment: environment)
}
if provider == .minimax {
return self.settings.minimaxAPIRegion.dashboardURL
}
if provider == .opencodego {
return self.settings.opencodegoDashboardURL
}
if provider == .wayfinder {
return WayfinderProviderImplementation.dashboardURL(
settings: self.settings,
environment: environment)
}
if provider == .zai {
return ZaiUsageFetcher.resolveDashboardURL(
region: self.settings.zaiAPIRegion,
environment: environment,
usageScope: self.settings.zaiEffectiveUsageScope())
}
if provider == .qoder {
return QoderProviderDescriptor.dashboardURL(
settings: self.settings.qoderSettingsSnapshot(tokenOverride: nil),
sourceLabel: self.store.sourceLabel(for: .qoder))
}
let meta = self.store.metadata(for: provider)
let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() {
meta.subscriptionDashboardURL ?? meta.dashboardURL
} else {
meta.dashboardURL
}
guard let urlString else { return nil }
return URL(string: urlString)
}
@objc func openCreditsPurchase() {
let preferred = self.lastMenuProvider
?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first)
let provider = preferred ?? .codex
guard provider == .codex else { return }
let dashboardURL = self.store.metadata(for: .codex).dashboardURL
let purchaseURL = Self.sanitizedCreditsPurchaseURL(self.store.openAIDashboard?.creditsPurchaseURL)
let urlString = purchaseURL ?? dashboardURL
guard let urlString,
let url = URL(string: urlString) else { return }
let autoStart = true
let accountEmail = self.store.codexAccountEmailForOpenAIDashboard()
let cacheScope = self.store.codexCookieCacheScopeForOpenAIWeb()
guard OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(
accountEmail: accountEmail,
cacheScope: cacheScope)
else {
self.creditsPurchaseWindow?.close()
self.creditsPurchaseWindow = nil
return
}
let controller = self.creditsPurchaseWindow ?? OpenAICreditsPurchaseWindowController()
controller.show(
purchaseURL: url,
accountEmail: accountEmail,
cacheScope: cacheScope,
autoStartPurchase: autoStart)
self.creditsPurchaseWindow = controller
}
static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? {
guard let raw, let url = URL(string: raw) else { return nil }
guard Self.isAllowedChatGPTPurchaseHost(url) else { return nil }
let pathComponents = url.pathComponents.map { $0.lowercased() }
let allowed = ["settings", "usage", "billing", "credits"]
guard pathComponents.contains(where: { allowed.contains($0) }) else { return nil }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.query = nil
components?.fragment = nil
return components?.url?.absoluteString ?? url.absoluteString
}
private static func isAllowedChatGPTPurchaseHost(_ url: URL) -> Bool {
guard url.scheme?.lowercased() == "https" else { return false }
guard let host = url.host?.lowercased() else { return false }
return host == "chatgpt.com" || host.hasSuffix(".chatgpt.com")
}
@objc func openStatusPage() {
let preferred = self.lastMenuProvider
?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first)
self.openStatusPage(for: preferred ?? .codex)
}
@objc func openStatusPageFromMenuItem(_ sender: NSMenuItem) {
let provider = (sender.identifier?.rawValue).flatMap(UsageProvider.init(rawValue:))
?? self.lastMenuProvider
?? .codex
self.openStatusPage(for: provider)
}
private func openStatusPage(for provider: UsageProvider) {
let meta = self.store.metadata(for: provider)
let urlString = meta.statusPageURL ?? meta.statusLinkURL
guard let urlString, let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
@objc func openChangelog() {
let preferred = self.lastMenuProvider
?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first)
let provider = preferred ?? .codex
let meta = self.store.metadata(for: provider)
guard let urlString = meta.changelogURL, let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
@objc func openTerminalCommand(_ sender: NSMenuItem) {
let command = sender.representedObject as? String ?? "claude"
self.openTerminal(command: command)
}
@objc func openLoginToProvider(_ sender: NSMenuItem) {
guard let urlString = sender.representedObject as? String,
let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
@objc func addManagedCodexAccountFromMenu(_: NSMenuItem) {
guard self.codexAccountPromotionCoordinator.isInteractionBlocked() == false else {
self.loginLogger.info("Add Account tap ignored: Codex account change already in-flight")
return
}
guard self.settings.hasUnreadableManagedCodexAccountStore == false else {
self.presentLoginAlert(
title: L("Managed Codex accounts unavailable"),
message: L(
"CodexBar could not read managed account storage. " +
"Recover the store before adding another account."))
return
}
Task { @MainActor [weak self] in
guard let self else { return }
do {
let account = try await self.managedCodexAccountCoordinator.authenticateManagedAccount()
self.settings.selectAuthenticatedManagedCodexAccount(account)
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshCodexAccountScopedState(allowDisabled: true)
}
} catch {
self.presentManagedCodexAccountError(error)
}
}
}
@objc func requestCodexSystemPromotionFromMenu(_ sender: NSMenuItem) {
guard let rawManagedAccountID = sender.representedObject as? String,
let managedAccountID = UUID(uuidString: rawManagedAccountID)
else {
return
}
Task { @MainActor [weak self] in
guard let self else { return }
let result = await self.codexAccountPromotionCoordinator.promote(managedAccountID: managedAccountID)
if case let .failure(error) = result {
self.presentLoginAlert(title: error.title, message: error.message)
}
}
}
@objc func runSwitchAccount(_ sender: NSMenuItem) {
if self.loginTask != nil {
self.loginLogger.info("Switch Account tap ignored: login already in-flight")
return
}
let rawProvider = sender.representedObject as? String
let provider = rawProvider.flatMap(UsageProvider.init(rawValue:)) ?? self.lastMenuProvider ?? .codex
self.loginLogger.info("Switch Account tapped", metadata: ["provider": provider.rawValue])
self.startLoginFlow(provider: provider)
}
func runLoginFlowFromSettings(provider: UsageProvider) async {
guard self.loginTask == nil else {
self.loginLogger.info(
"Settings login tap ignored: login already in-flight",
metadata: ["provider": provider.rawValue])
return
}
self.startLoginFlow(provider: provider)
await self.loginTask?.value
}
@objc func showSettingsGeneral() {
// Restore the last selected pane; only About navigates explicitly.
self.openSettings(pane: nil)
}
@objc func showSettingsAbout() {
self.openSettings(pane: .about)
}
func openMenuFromShortcut() {
if self.closeOpenMenusFromShortcutIfNeeded() {
return
}
if self.shouldMergeIcons {
self.statusItem.button?.performClick(nil)
return
}
let provider = self.resolvedShortcutProvider()
// Use the lazy accessor to ensure the item exists
let item = self.lazyStatusItem(for: provider)
item.button?.performClick(nil)
}
@discardableResult
func closeOpenMenusFromShortcutIfNeeded() -> Bool {
guard !self.openMenus.isEmpty else { return false }
let menus = Array(self.openMenus.values)
for menu in menus {
menu.cancelTrackingWithoutAnimation()
self.forgetClosedMenu(menu)
}
return true
}
func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? {
let item: NSStatusItem = if self.shouldMergeIcons {
self.statusItem
} else if let provider, let existing = self.statusItems[provider], existing.isVisible {
existing
} else {
self.lazyStatusItem(for: provider ?? .codex)
}
guard let button = item.button,
let window = button.window
else {
return nil
}
let buttonFrameInWindow = button.convert(button.bounds, to: nil)
let screenFrame = window.convertToScreen(buttonFrameInWindow)
return CGPoint(x: screenFrame.midX, y: screenFrame.midY)
}
private func openSettings(pane: SettingsPane?) {
DispatchQueue.main.async {
if let pane {
self.preferencesSelection.pane = pane
}
NSApp.activate(ignoringOtherApps: true)
NotificationCenter.default.post(name: .codexbarOpenSettings, object: nil)
}
}
@objc func quit() {
let openMenus = Array(self.openMenus.values)
for menu in openMenus {
menu.cancelTrackingWithoutAnimation()
}
self.scheduleQuitTermination { [weak self] in
guard let self else { return }
self.prepareForAppShutdown()
self.terminateApplicationForQuit()
}
}
@objc func copyError(_ sender: NSMenuItem) {
if let err = sender.representedObject as? String {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(err, forType: .string)
}
}
func openTerminal(command: String) {
let terminal = self.settings.terminalApp
if terminal == .iTerm, !terminal.isInstalled {
CodexBarLog.logger(LogCategories.terminal).warning(
"iTerm is not installed, falling back to Terminal.app",
metadata: ["terminal": terminal.rawValue])
Self.openTerminalInDefaultTerminal(command: command)
return
}
if Self.executeAppleScript(terminal.appleScript(command: command)) {
return
}
guard terminal != .terminal else { return }
CodexBarLog.logger(LogCategories.terminal).warning(
"\(terminal.label) AppleScript failed, falling back to Terminal.app",
metadata: ["terminal": terminal.rawValue])
Self.openTerminalInDefaultTerminal(command: command)
}
private static func openTerminalInDefaultTerminal(command: String) {
self.executeAppleScript(TerminalApp.terminal.appleScript(command: command))
}
/// Executes an AppleScript and returns `true` on success, `false` on failure.
@discardableResult
private static func executeAppleScript(_ source: String) -> Bool {
if let appleScript = NSAppleScript(source: source) {
var error: NSDictionary?
appleScript.executeAndReturnError(&error)
if let error {
CodexBarLog.logger(LogCategories.terminal).error(
"Failed to execute AppleScript",
metadata: ["error": String(describing: error)])
return false
}
return true
}
CodexBarLog.logger(LogCategories.terminal).error("Failed to compile AppleScript")
return false
}
private func resolvedShortcutProvider() -> UsageProvider {
if let last = self.lastMenuProvider, self.isEnabled(last) {
return last
}
if let first = self.store.enabledProviders().first {
return first
}
return .codex
}
private func startLoginFlow(provider: UsageProvider) {
self.loginTask = Task { @MainActor [weak self] in
guard let self else { return }
defer {
self.activeLoginProvider = nil
self.loginTask = nil
}
self.activeLoginProvider = provider
self.loginPhase = .requesting
self.loginLogger.info("Starting login task", metadata: ["provider": provider.rawValue])
let shouldRefresh = await self.runLoginFlow(provider: provider)
if shouldRefresh {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refresh()
}
self.loginLogger.info("Triggered refresh after login", metadata: ["provider": provider.rawValue])
}
}
}
func presentCodexLoginResult(_ result: CodexLoginRunner.Result) {
guard let info = CodexLoginAlertPresentation.alertInfo(for: result) else { return }
self.presentLoginAlert(title: info.title, message: info.message)
}
private func presentManagedCodexAccountError(_ error: Error) {
let info = if let error = error as? ManagedCodexAccountCoordinatorError,
error == .authenticationInProgress
{
LoginAlertInfo(
title: L("Codex account login already running"),
message: L("Wait for the current managed Codex login to finish before adding another account."))
} else if let error = error as? ManagedCodexAccountServiceError {
LoginAlertInfo(title: L("Could not add Codex account"), message: error.userFacingMessage)
} else {
LoginAlertInfo(title: L("Could not add Codex account"), message: error.localizedDescription)
}
self.presentLoginAlert(title: info.title, message: info.message)
}
func presentClaudeLoginResult(_ result: ClaudeLoginRunner.Result) {
switch result.outcome {
case .success:
return
case .missingBinary:
self.presentLoginAlert(
title: L("Claude CLI not found"),
message: L("Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."))
case let .launchFailed(message):
self.presentLoginAlert(title: L("Could not start Claude Code login"), message: message)
case .timedOut:
self.presentLoginAlert(
title: L("Claude login timed out"),
message: self.trimmedLoginOutput(result.output))
case let .failed(status):
let statusLine = String(format: L("claude auth login exited with status %d."), status)
let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output)
self.presentLoginAlert(title: L("Claude login failed"), message: message)
}
}
func describe(_ outcome: CodexLoginRunner.Result.Outcome) -> String {
switch outcome {
case .success: "success"
case .timedOut: "timedOut"
case let .failed(status): "failed(status: \(status))"
case .missingBinary: "missingBinary"
case let .launchFailed(message): "launchFailed(\(message))"
}
}
func describe(_ outcome: ClaudeLoginRunner.Result.Outcome) -> String {
switch outcome {
case .success: "success"
case .timedOut: "timedOut"
case let .failed(status): "failed(status: \(status))"
case .missingBinary: "missingBinary"
case let .launchFailed(message): "launchFailed(\(message))"
}
}
func describe(_ outcome: GeminiLoginRunner.Result.Outcome) -> String {
switch outcome {
case .success: "success"
case .missingBinary: "missingBinary"
case let .launchFailed(message): "launchFailed(\(message))"
}
}
func describe(_ outcome: AntigravityLoginRunner.Result.Outcome) -> String {
switch outcome {
case let .success(email):
"success(email: \(email ?? "nil"))"
case .cancelled:
"cancelled"
case .timedOut:
"timedOut"
case let .launchFailed(message):
"launchFailed(\(message))"
case let .failed(message):
"failed(\(message))"
}
}
func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) {
guard let info = Self.geminiLoginAlertInfo(for: result) else { return }
self.presentLoginAlert(title: info.title, message: info.message)
}
func presentAntigravityLoginResult(_ result: AntigravityLoginRunner.Result) {
guard let info = Self.antigravityLoginAlertInfo(for: result) else { return }
self.presentLoginAlert(title: info.title, message: info.message)
}
struct LoginAlertInfo: Equatable {
let title: String
let message: String
}
nonisolated static func geminiLoginAlertInfo(for result: GeminiLoginRunner.Result) -> LoginAlertInfo? {
switch result.outcome {
case .success:
nil
case .missingBinary:
LoginAlertInfo(
title: L("Gemini CLI not found"),
message: L("Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."))
case let .launchFailed(message):
LoginAlertInfo(title: L("Could not open Terminal for Gemini"), message: message)
}
}
nonisolated static func antigravityLoginAlertInfo(for result: AntigravityLoginRunner.Result) -> LoginAlertInfo? {
switch result.outcome {
case .success, .cancelled:
nil
case .timedOut:
LoginAlertInfo(
title: L("Antigravity login timed out"),
message: L("The browser login did not complete in time. Try Antigravity login again."))
case let .launchFailed(message):
LoginAlertInfo(
title: L("Could not open browser for Antigravity"),
message: String(format: L("Open this URL manually to continue login:\n\n%@"), message))
case let .failed(message):
LoginAlertInfo(title: L("Antigravity login failed"), message: message)
}
}
func presentLoginAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = L(title)
alert.informativeText = L(message)
alert.alertStyle = .warning
alert.runModal()
}
private func trimmedLoginOutput(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let limit = 600
if trimmed.isEmpty {
return L("No output captured.")
}
if trimmed.count <= limit {
return trimmed
}
let idx = trimmed.index(trimmed.startIndex, offsetBy: limit)
return "\(trimmed[..<idx])…"
}
func postLoginNotification(for provider: UsageProvider) {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
let (title, body) = LoginNotificationLogic.notificationCopy(providerName: name)
AppNotifications.shared.post(idPrefix: "login-\(provider.rawValue)", title: title, body: body)
}
func presentCursorLoginResult(_ result: CursorLoginRunner.Result) {
switch result.outcome {
case .success:
return
case .cancelled:
// User closed the window; no alert needed
return
case let .failed(message):
self.presentLoginAlert(title: L("Cursor login failed"), message: message)
}
}
func describe(_ outcome: CursorLoginRunner.Result.Outcome) -> String {
switch outcome {
case .success: "success"
case .cancelled: "cancelled"
case let .failed(message): "failed(\(message))"
}
}
}