882 lines
34 KiB
Swift
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))"
|
|
}
|
|
}
|
|
}
|