import AppKit import CodexBarCore extension StatusItemController { // MARK: - Actions reachable from menus @objc func refreshNow() { Task { await self.store.refresh(forceTokenUsage: true) } } @objc func installUpdate() { self.updater.checkForUpdates(nil) } @objc func openDashboard() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) let provider = preferred ?? .codex let meta = self.store.metadata(for: provider) // For Claude, route subscription users to claude.ai/settings/usage instead of console billing let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL } else { meta.dashboardURL } guard let urlString, let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } @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 controller = self.creditsPurchaseWindow ?? OpenAICreditsPurchaseWindowController() controller.show(purchaseURL: url, accountEmail: accountEmail, autoStartPurchase: autoStart) self.creditsPurchaseWindow = controller } private static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? { guard let raw, let url = URL(string: raw) else { return nil } guard let host = url.host?.lowercased(), host.contains("chatgpt.com") else { return nil } let path = url.path.lowercased() let allowed = ["settings", "usage", "billing", "credits"] guard allowed.contains(where: { path.contains($0) }) else { return nil } return url.absoluteString } @objc func openStatusPage() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) let provider = preferred ?? .codex 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 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 runSwitchAccount(_ sender: NSMenuItem) { if self.loginTask != nil { self.loginLogger.info("Switch Account tap ignored: login already in-flight") print("[CodexBar] Switch Account ignored (busy)") 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]) print("[CodexBar] Switch Account tapped for provider=\(provider.rawValue)") 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]) print("[CodexBar] Starting login task for \(provider.rawValue)") let shouldRefresh = await self.runLoginFlow(provider: provider) if shouldRefresh { await self.store.refresh() print("[CodexBar] Triggered refresh after login") } } } @objc func showSettingsGeneral() { self.openSettings(tab: .general) } @objc func showSettingsAbout() { self.openSettings(tab: .about) } func openMenuFromShortcut() { if self.shouldMergeIcons { self.statusItem.button?.performClick(nil) return } let provider = self.resolvedShortcutProvider() let item = self.statusItems[provider] ?? self.statusItem item.button?.performClick(nil) } private func openSettings(tab: PreferencesTab) { DispatchQueue.main.async { self.preferencesSelection.tab = tab NSApp.activate(ignoringOtherApps: true) NotificationCenter.default.post( name: .codexbarOpenSettings, object: nil, userInfo: ["tab": tab.rawValue]) } } @objc func quit() { NSApp.terminate(nil) } @objc func copyError(_ sender: NSMenuItem) { if let err = sender.representedObject as? String { let pb = NSPasteboard.general pb.clearContents() pb.setString(err, forType: .string) } } private static func openTerminal(command: String) { let escaped = command .replacingOccurrences(of: "\\\\", with: "\\\\\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let script = """ tell application "Terminal" activate do script "\(escaped)" end tell """ if let appleScript = NSAppleScript(source: script) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { NSLog("Failed to open Terminal: \(error)") } } } 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 } func presentCodexLoginResult(_ result: CodexLoginRunner.Result) { switch result.outcome { case .success: return case .missingBinary: self.presentLoginAlert( title: "Codex CLI not found", message: "Install the Codex CLI (npm i -g @openai/codex) and try again.") case let .launchFailed(message): self.presentLoginAlert(title: "Could not start codex login", message: message) case .timedOut: self.presentLoginAlert( title: "Codex login timed out", message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "codex login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: "Codex login failed", message: message) } } func presentClaudeLoginResult(_ result: ClaudeLoginRunner.Result) { switch result.outcome { case .success: return case .missingBinary: self.presentLoginAlert( title: "Claude CLI not found", message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-cli) and try again.") case let .launchFailed(message): self.presentLoginAlert(title: "Could not start claude /login", message: message) case .timedOut: self.presentLoginAlert( title: "Claude login timed out", message: self.trimmedLoginOutput(result.output)) case let .failed(status): let statusLine = "claude /login exited with status \(status)." let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: "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 presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) { guard let info = Self.geminiLoginAlertInfo(for: result) else { return } self.presentLoginAlert(title: info.title, message: info.message) } struct LoginAlertInfo: Equatable, Sendable { 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: "Gemini CLI not found", message: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.") case let .launchFailed(message): LoginAlertInfo(title: "Could not open Terminal for Gemini", message: message) } } func presentLoginAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = 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 "No output captured." } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. String { switch outcome { case .success: "success" case .cancelled: "cancelled" case let .failed(message): "failed(\(message))" } } }