From afda0812a274ccb9c0f9d1ecac8956edcf10a59a Mon Sep 17 00:00:00 2001 From: Monter <261478260+monterrr@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:44:13 +0100 Subject: [PATCH 1/3] Add Codex multi-account menu support Co-Authored-By: Craft Agent --- FORK_STATUS.md | 7 + .../CodexBar/ChatGPTAccountLoginRunner.swift | 430 ++++++++++++++++++ .../CodexAccountSortControlView.swift | 126 +++++ .../CodexBar/CodexMenuAccountSortMode.swift | 40 ++ Sources/CodexBar/MenuCardView.swift | 80 +++- .../PreferencesProviderDetailView.swift | 12 +- .../PreferencesProviderSettingsRows.swift | 16 + .../PreferencesProvidersPane+Testing.swift | 2 + .../CodexBar/PreferencesProvidersPane.swift | 34 ++ .../Codex/CodexProviderImplementation.swift | 28 +- .../Codex/CodexProviderRuntime.swift | 45 ++ .../Providers/Codex/CodexSettingsStore.swift | 26 +- .../Shared/ProviderSettingsDescriptors.swift | 2 + .../SettingsStore+CodexMenuSort.swift | 8 + Sources/CodexBar/SettingsStore+Defaults.swift | 38 ++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + ...tatusItemController+CodexMenuSorting.swift | 97 ++++ .../CodexBar/StatusItemController+Menu.swift | 395 +++------------- .../StatusItemController+OpenAIWebMenu.swift | 202 ++++++++ .../StatusItemController+OverviewMenu.swift | 97 ++++ .../UsageStore+TokenAccountHelpers.swift | 102 +++++ .../CodexBar/UsageStore+TokenAccounts.swift | 116 +++-- Sources/CodexBarCLI/TokenAccountCLI.swift | 7 +- ...OpenAIDashboardBrowserCookieImporter.swift | 164 ++++--- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 16 +- .../OpenAIDashboardWebsiteDataStore.swift | 49 +- .../Providers/Codex/CodexAccountLabel.swift | 61 +++ .../Codex/CodexWebDashboardStrategy.swift | 115 +++-- .../Providers/ProviderSettingsSnapshot.swift | 11 +- .../TokenAccountSupportCatalog+Data.swift | 7 + .../ProviderSettingsDescriptorTests.swift | 122 +++++ Tests/CodexBarTests/StatusMenuTests.swift | 9 +- ...kenAccountEnvironmentPrecedenceTests.swift | 23 + docs/CODEX_MULTI_ACCOUNT.md | 79 ++++ docs/FORK_WORKING_RULES.md | 33 ++ 37 files changed, 2074 insertions(+), 529 deletions(-) create mode 100644 Sources/CodexBar/ChatGPTAccountLoginRunner.swift create mode 100644 Sources/CodexBar/CodexAccountSortControlView.swift create mode 100644 Sources/CodexBar/CodexMenuAccountSortMode.swift create mode 100644 Sources/CodexBar/SettingsStore+CodexMenuSort.swift create mode 100644 Sources/CodexBar/StatusItemController+CodexMenuSorting.swift create mode 100644 Sources/CodexBar/StatusItemController+OpenAIWebMenu.swift create mode 100644 Sources/CodexBar/StatusItemController+OverviewMenu.swift create mode 100644 Sources/CodexBar/UsageStore+TokenAccountHelpers.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexAccountLabel.swift create mode 100644 docs/CODEX_MULTI_ACCOUNT.md create mode 100644 docs/FORK_WORKING_RULES.md diff --git a/FORK_STATUS.md b/FORK_STATUS.md index cb0a07fdf..7f991dddf 100644 --- a/FORK_STATUS.md +++ b/FORK_STATUS.md @@ -72,6 +72,13 @@ - ⚠️ Augment cookie disconnection (Phase 2 will address) - ⚠️ Debug print statements in AugmentStatusProbe.swift (needs proper logging) +### Known Local Test Harness Limitation (2026-03-22) +- ⚠️ Local `swift test` runs for AppKit status-bar suites can crash under `swiftpm-testing-helper` when tests instantiate a standalone `NSStatusBar()`. +- Reproduced with `StatusItemAnimationTests` and `StatusMenuTests` on local runs; crash reports point into AppKit drawing (`NSStatusBarButtonCell drawWithFrame:inView:`) with `EXC_BAD_ACCESS` / `SIGSEGV` or `SIGBUS`. +- The same suites pass with `CI=true`, which makes the test helper use `NSStatusBar.system` instead of `NSStatusBar()`. +- Current assessment: this looks like a local AppKit test-harness issue, not a product regression in the running app. +- Temporary workaround for local verification: run affected suites with `CI=true` until the test harness is adjusted. + ### Uncommitted Changes - `Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift` has debug print statements - These should be replaced with proper `CodexBarLog` logging in Phase 2 diff --git a/Sources/CodexBar/ChatGPTAccountLoginRunner.swift b/Sources/CodexBar/ChatGPTAccountLoginRunner.swift new file mode 100644 index 000000000..1c83d2772 --- /dev/null +++ b/Sources/CodexBar/ChatGPTAccountLoginRunner.swift @@ -0,0 +1,430 @@ +import AppKit +import CodexBarCore +import Foundation +import WebKit + +@MainActor +final class ChatGPTAccountLoginRunner: NSObject { + enum Phase: Sendable { + case loading + case waitingLogin + case capturing + case success + case failed(String) + } + + struct Result: Sendable { + enum Outcome: Sendable { + case success(cookieHeader: String, email: String?, workspaceLabel: String?) + case cancelled + case failed(String) + } + + let outcome: Outcome + } + + private let browserDetection: BrowserDetection + private let logger = CodexBarLog.logger(LogCategories.openAIWeb) + private var webView: WKWebView? + private var window: NSWindow? + private var continuation: CheckedContinuation? + private var phaseCallback: ((Phase) -> Void)? + private var isCompleting = false + private var captureTask: Task? + private var pollingTask: Task? + private var timeoutTask: Task? + private var debugLines: [String] = [] + + private static let initialURL = URL(string: "https://chatgpt.com/")! + private static let loginHosts = ["auth.openai.com", "auth.chatgpt.com"] + private static let timeoutSeconds: UInt64 = 120 + + init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + super.init() + } + + private func log(_ message: String) { + let stamped = "[chatgpt-login] \(message)" + self.logger.info("\(stamped)") + self.debugLines.append(stamped) + if self.debugLines.count > 200 { + self.debugLines.removeFirst(self.debugLines.count - 200) + } + } + + private func debugDump() -> String { + self.debugLines.joined(separator: "\n") + } + + func run(onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { + WebKitTeardown.retain(self) + self.phaseCallback = onPhaseChange + onPhaseChange(.loading) + self.log("login flow started") + + return await withCheckedContinuation { continuation in + self.continuation = continuation + self.setupWindow() + } + } + + private func setupWindow() { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 520, height: 760), configuration: config) + webView.navigationDelegate = self + self.webView = webView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 760), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false) + window.isReleasedWhenClosed = false + window.title = "Add ChatGPT Account" + window.contentView = webView + window.center() + window.delegate = self + window.makeKeyAndOrderFront(nil) + self.window = window + self.startPollingForSession() + self.startTimeoutWatchdog() + self.log("window opened") + + webView.load(URLRequest(url: Self.initialURL)) + } + + private func scheduleCleanup() { + self.captureTask?.cancel() + self.pollingTask?.cancel() + self.timeoutTask?.cancel() + WebKitTeardown.scheduleCleanup(owner: self, window: self.window, webView: self.webView) + } + + private func complete(with result: Result) { + guard !self.isCompleting, let continuation = self.continuation else { return } + self.isCompleting = true + self.continuation = nil + self.scheduleCleanup() + continuation.resume(returning: result) + } + + private func currentPhase(for url: URL?) -> Phase { + guard let host = url?.host?.lowercased() else { return .loading } + if Self.loginHosts.contains(host) { return .waitingLogin } + if host.contains("chatgpt.com") || host.contains("openai.com") { return .capturing } + return .loading + } + + private func startPollingForSession() { + self.pollingTask?.cancel() + self.pollingTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled, !self.isCompleting { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, !self.isCompleting else { return } + await self.attemptCapture() + } + } + } + + private func startTimeoutWatchdog() { + self.timeoutTask?.cancel() + self.timeoutTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: Self.timeoutSeconds * 1_000_000_000) + guard !Task.isCancelled, !self.isCompleting else { return } + self.log("timed out waiting for captured session") + self.phaseCallback?(.failed("Timed out waiting for ChatGPT session")) + self.complete(with: Result(outcome: .failed(self.debugDump()))) + } + } + + private func scheduleCaptureAttempt() { + self.captureTask?.cancel() + self.captureTask = Task { @MainActor [weak self] in + guard let self else { return } + self.phaseCallback?(.capturing) + try? await Task.sleep(nanoseconds: 700_000_000) + guard !Task.isCancelled else { return } + await self.attemptCapture() + } + } + + private func attemptCapture() async { + guard let webView = self.webView, !self.isCompleting else { return } + let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() + let relevant = cookies.filter { cookie in + let domain = cookie.domain.lowercased() + return domain.contains("chatgpt.com") || domain.contains("openai.com") + } + guard !relevant.isEmpty else { + self.log("capture attempt: 0 relevant cookies") + return + } + + let cookieNames = relevant.map(\.name).sorted().joined(separator: ", ") + self.log("capture attempt: \(relevant.count) relevant cookies [\(cookieNames)]") + + let header = relevant.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + let apiEmail = await self.fetchSignedInEmail(from: relevant)? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let workspaceLabel = self.resolveWorkspaceLabel(from: relevant) + if let workspaceLabel, !workspaceLabel.isEmpty { + self.log("resolved workspace label: \(workspaceLabel)") + } + + if let apiEmail, !apiEmail.isEmpty { + self.log("session API identified signed-in account: \(apiEmail)") + if self.hasLikelySessionCookies(relevant) { + self.log("captured signed-in API session with session cookies") + await self.persistCapturedCookies( + relevant, + accountEmail: apiEmail, + workspaceLabel: workspaceLabel) + self.phaseCallback?(.success) + self.complete(with: Result(outcome: .success( + cookieHeader: header, + email: apiEmail, + workspaceLabel: workspaceLabel))) + return + } + } + + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) + do { + let result = try await importer.importManualCookies( + cookieHeader: header, + intoAccountEmail: nil, + intoWorkspaceLabel: workspaceLabel, + allowAnyAccount: true, + logger: { [weak self] line in self?.log(line) }) + let resolvedEmail = result.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let labelEmail = (resolvedEmail?.isEmpty == false) ? resolvedEmail : apiEmail + self.log("importer validated session as \(labelEmail ?? "unknown")") + self.phaseCallback?(.success) + self.complete(with: Result(outcome: .success( + cookieHeader: header, + email: labelEmail, + workspaceLabel: workspaceLabel))) + } catch let error as OpenAIDashboardBrowserCookieImporter.ImportError { + self.log("importer validation failed: \(error.localizedDescription)") + switch error { + case .manualCookieHeaderInvalid, .dashboardStillRequiresLogin, .noMatchingAccount: + self.phaseCallback?(.capturing) + return + case .noCookiesFound, .browserAccessDenied: + self.phaseCallback?(.failed(error.localizedDescription)) + self.complete(with: Result(outcome: .failed(error.localizedDescription))) + } + } catch { + self.log("capture validation error: \(error.localizedDescription)") + self.phaseCallback?(.capturing) + return + } + } + + private func hasLikelySessionCookies(_ cookies: [HTTPCookie]) -> Bool { + for cookie in cookies { + let name = cookie.name.lowercased() + if name.contains("session-token") || name.contains("authjs") || name.contains("next-auth") { + return true + } + if name == "_account" || name == "oai-client-auth-session" { return true } + } + return false + } + + private func persistCapturedCookies( + _ cookies: [HTTPCookie], + accountEmail: String, + workspaceLabel: String?) async + { + let store = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: accountEmail, + workspaceLabel: workspaceLabel) + await self.clearChatGPTCookies(in: store) + await self.setCookies(cookies, into: store) + self.log("persisted captured cookies for \(accountEmail)\(workspaceLabel.map { " [\($0)]" } ?? "")") + } + + private func resolveWorkspaceLabel(from cookies: [HTTPCookie]) -> String? { + guard let accountID = cookies.first(where: { $0.name == "_account" })?.value, + !accountID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let sessionCookie = cookies.first(where: { $0.name == "oai-client-auth-session" })?.value, + let payload = self.decodeBase64URLJSONPayload(fromCookieValue: sessionCookie), + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let workspaces = json["workspaces"] as? [[String: Any]] + else { + return nil + } + + guard let workspace = workspaces.first(where: { + ($0["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) == accountID + }) else { + return nil + } + + let name = (workspace["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let name, !name.isEmpty { return name } + + let kind = (workspace["kind"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if kind == "personal" { return "Personal" } + return nil + } + + private func decodeBase64URLJSONPayload(fromCookieValue value: String) -> Data? { + let prefix = value.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? value + guard !prefix.isEmpty else { return nil } + var base64 = prefix.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder != 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: base64) + } + + private func clearChatGPTCookies(in store: WKWebsiteDataStore) async { + await withCheckedContinuation { cont in + store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in + let filtered = records.filter { record in + let name = record.displayName.lowercased() + return name.contains("chatgpt.com") || name.contains("openai.com") + } + store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: filtered) { + cont.resume() + } + } + } + } + + private func setCookies(_ cookies: [HTTPCookie], into store: WKWebsiteDataStore) async { + for cookie in cookies { + await withCheckedContinuation { cont in + store.httpCookieStore.setCookie(cookie) { cont.resume() } + } + } + } + + private func fetchSignedInEmail(from cookies: [HTTPCookie]) async -> String? { + let chatgptCookies = cookies.filter { $0.domain.lowercased().contains("chatgpt.com") } + guard !chatgptCookies.isEmpty else { + self.log("session API skipped: no chatgpt.com cookies") + return nil + } + + let cookieHeader = chatgptCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + let endpoints = [ + "https://chatgpt.com/backend-api/me", + "https://chatgpt.com/api/auth/session", + ] + + for urlString in endpoints { + guard let url = URL(string: urlString) else { continue } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 10 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + self.log("session API \(url.path) status=\(status)") + guard status >= 200, status < 300 else { continue } + if let email = Self.findFirstEmail(inJSONData: data) { + return email.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + } catch { + self.log("session API request failed for \(url.path): \(error.localizedDescription)") + } + } + + return nil + } + + private static func findFirstEmail(inJSONData data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + var queue: [Any] = [json] + var seen = 0 + while !queue.isEmpty, seen < 2000 { + let cur = queue.removeFirst() + seen += 1 + if let str = cur as? String, str.contains("@") { return str } + if let dict = cur as? [String: Any] { + for (k, v) in dict { + if k.lowercased() == "email", let s = v as? String, s.contains("@") { return s } + queue.append(v) + } + } else if let arr = cur as? [Any] { + queue.append(contentsOf: arr) + } + } + return nil + } +} + +extension ChatGPTAccountLoginRunner: WKNavigationDelegate { + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + let phase = self.currentPhase(for: webView.url) + let urlString = webView.url?.absoluteString ?? "unknown" + self.log( + "didFinish navigation url=\(urlString) phase=\(String(describing: phase))") + self.phaseCallback?(phase) + if case .waitingLogin = phase { return } + self.scheduleCaptureAttempt() + } + } + + nonisolated func webView( + _ webView: WKWebView, + didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) + { + Task { @MainActor in + let phase = self.currentPhase(for: webView.url) + self.log("redirect url=\(webView.url?.absoluteString ?? "unknown") phase=\(String(describing: phase))") + self.phaseCallback?(phase) + if case .waitingLogin = phase { return } + self.scheduleCaptureAttempt() + } + } + + nonisolated func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error) + { + Task { @MainActor in + self.phaseCallback?(.failed(error.localizedDescription)) + self.complete(with: Result(outcome: .failed(error.localizedDescription))) + } + } + + nonisolated func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) + { + Task { @MainActor in + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + return + } + self.phaseCallback?(.failed(error.localizedDescription)) + self.complete(with: Result(outcome: .failed(error.localizedDescription))) + } + } +} + +extension ChatGPTAccountLoginRunner: NSWindowDelegate { + nonisolated func windowWillClose(_ notification: Notification) { + Task { @MainActor in + guard !self.isCompleting else { return } + self.complete(with: Result(outcome: .cancelled)) + } + } +} diff --git a/Sources/CodexBar/CodexAccountSortControlView.swift b/Sources/CodexBar/CodexAccountSortControlView.swift new file mode 100644 index 000000000..0d4cf1c34 --- /dev/null +++ b/Sources/CodexBar/CodexAccountSortControlView.swift @@ -0,0 +1,126 @@ +import AppKit +import Foundation + +final class CodexAccountSortControlView: NSView { + private let onStep: (Int) -> Void + private let currentMode: CodexMenuAccountSortMode + private let titleLabel: NSTextField + private let modeLabel: NSTextField + private let previousButton: NSButton + private let nextButton: NSButton + + init(mode: CodexMenuAccountSortMode, width: CGFloat, onStep: @escaping (Int) -> Void) { + self.currentMode = mode + self.onStep = onStep + self.titleLabel = NSTextField(labelWithString: "Sort") + self.modeLabel = NSTextField(labelWithString: mode.compactTitle) + self.previousButton = NSButton(title: "", target: nil, action: nil) + self.nextButton = NSButton(title: "", target: nil, action: nil) + super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30)) + self.wantsLayer = true + self.buildUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: NSSize { + NSSize(width: self.frame.width, height: 30) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func layout() { + super.layout() + let bounds = self.bounds.insetBy(dx: 10, dy: 0) + let centerY = bounds.midY + let buttonSize = NSSize(width: 20, height: 20) + + self.nextButton.frame = NSRect( + x: bounds.maxX - buttonSize.width, + y: centerY - buttonSize.height / 2, + width: buttonSize.width, + height: buttonSize.height) + + self.previousButton.frame = NSRect( + x: self.nextButton.frame.minX - 6 - buttonSize.width, + y: centerY - buttonSize.height / 2, + width: buttonSize.width, + height: buttonSize.height) + + self.titleLabel.sizeToFit() + self.titleLabel.frame = NSRect( + x: bounds.minX, + y: centerY - 9, + width: min(60, self.titleLabel.frame.width), + height: 18) + + let modeX = self.titleLabel.frame.maxX + 8 + let modeWidth = max(40, self.previousButton.frame.minX - 8 - modeX) + self.modeLabel.frame = NSRect( + x: modeX, + y: centerY - 9, + width: modeWidth, + height: 18) + } + + private func buildUI() { + self.titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + self.titleLabel.textColor = .labelColor + self.titleLabel.alignment = .left + self.titleLabel.lineBreakMode = .byClipping + + self.modeLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + self.modeLabel.textColor = .secondaryLabelColor + self.modeLabel.alignment = .right + self.modeLabel.lineBreakMode = .byTruncatingTail + + self.configureButton(self.previousButton, action: #selector(self.previousMode)) + self.configureButton(self.nextButton, action: #selector(self.nextMode)) + + if let previousImage = NSImage( + systemSymbolName: "chevron.left", + accessibilityDescription: "Previous sort mode") + { + previousImage.isTemplate = true + self.previousButton.image = previousImage + } else { + self.previousButton.title = "◀" + } + if let nextImage = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Next sort mode") { + nextImage.isTemplate = true + self.nextButton.image = nextImage + } else { + self.nextButton.title = "▶" + } + + self.addSubview(self.titleLabel) + self.addSubview(self.modeLabel) + self.addSubview(self.previousButton) + self.addSubview(self.nextButton) + } + + private func configureButton(_ button: NSButton, action: Selector) { + button.target = self + button.action = action + button.isBordered = true + button.bezelStyle = .texturedRounded + button.font = NSFont.systemFont(ofSize: 10, weight: .semibold) + button.contentTintColor = .secondaryLabelColor + button.setButtonType(.momentaryPushIn) + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + } + + @objc private func previousMode() { + self.onStep(-1) + } + + @objc private func nextMode() { + self.onStep(1) + } +} diff --git a/Sources/CodexBar/CodexMenuAccountSortMode.swift b/Sources/CodexBar/CodexMenuAccountSortMode.swift new file mode 100644 index 000000000..ff4637fb6 --- /dev/null +++ b/Sources/CodexBar/CodexMenuAccountSortMode.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Sorting is part of the multi-account Codex workflow: once several accounts are visible +/// together, it becomes useful to order them by reset time or remaining quota, not just by name. +enum CodexMenuAccountSortMode: String, CaseIterable, Sendable { + case accountNameAscending = "account-name-ascending" + case accountNameDescending = "account-name-descending" + case sessionLeftHighToLow = "session-left-high-to-low" + case sessionResetSoonestFirst = "session-reset-soonest-first" + case weeklyLeftHighToLow = "weekly-left-high-to-low" + case weeklyResetSoonestFirst = "weekly-reset-soonest-first" + + static let `default`: Self = .accountNameAscending + + var menuTitle: String { + switch self { + case .accountNameAscending: "Name A–Z" + case .accountNameDescending: "Name Z–A" + case .sessionLeftHighToLow: "Session left ↓" + case .sessionResetSoonestFirst: "Session reset soonest" + case .weeklyLeftHighToLow: "Weekly left ↓" + case .weeklyResetSoonestFirst: "Weekly reset soonest" + } + } + + var compactTitle: String { + switch self { + case .accountNameAscending: "Name A–Z" + case .accountNameDescending: "Name Z–A" + case .sessionLeftHighToLow: "Session ↓" + case .sessionResetSoonestFirst: "Session reset soonest" + case .weeklyLeftHighToLow: "Weekly ↓" + case .weeklyResetSoonestFirst: "Weekly reset soonest" + } + } + + var topBarTitle: String { + "Sort: \(self.menuTitle)" + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7039e1419..22567ea80 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -64,6 +64,7 @@ struct UsageMenuCardView: View { let provider: UsageProvider let providerName: String let email: String + let accountLabel: String? let subtitleText: String let subtitleStyle: SubtitleStyle let planText: String? @@ -119,7 +120,8 @@ struct UsageMenuCardView: View { MetricRow( metric: metric, title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + progressColor: self.model.progressColor, + compactPercentInHeader: self.model.provider == .codex && metric.id == "primary") } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -204,7 +206,7 @@ private struct UsageMenuCardHeaderView: View { .font(.headline) .fontWeight(.semibold) Spacer() - Text(self.model.email) + Text(self.headerTrailingText) .font(.subheadline) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } @@ -239,6 +241,14 @@ private struct UsageMenuCardHeaderView: View { case .error: MenuHighlightStyle.error(self.isHighlighted) } } + + private var headerTrailingText: String { + let label = self.model.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !label.isEmpty { + return label + } + return self.model.email + } } private struct CopyIconButtonStyle: ButtonStyle { @@ -323,13 +333,29 @@ private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String let progressColor: Color + let compactPercentInHeader: Bool @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 6) { - Text(self.title) - .font(.body) - .fontWeight(.medium) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(self.title) + .font(.body) + .fontWeight(.medium) + if self.compactPercentInHeader { + Text(self.metric.percentLabel) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + Spacer() + if self.compactPercentInHeader, let rightLabel = self.metric.resetText { + Text(rightLabel) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, @@ -338,11 +364,13 @@ private struct MetricRow: View { paceOnTop: self.metric.paceOnTop) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { - Text(self.metric.percentLabel) - .font(.footnote) - .lineLimit(1) + if !self.compactPercentInHeader { + Text(self.metric.percentLabel) + .font(.footnote) + .lineLimit(1) + } Spacer() - if let rightLabel = self.metric.resetText { + if !self.compactPercentInHeader, let rightLabel = self.metric.resetText { Text(rightLabel) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) @@ -439,7 +467,8 @@ struct UsageMenuCardUsageSectionView: View { MetricRow( metric: metric, title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + progressColor: self.model.progressColor, + compactPercentInHeader: self.model.provider == .codex && metric.id == "primary") } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -626,6 +655,7 @@ extension UsageMenuCardView.Model { let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? let account: AccountInfo + let accountLabel: String? let isRefreshing: Bool let lastError: String? let usageBarsShowUsed: Bool @@ -649,6 +679,7 @@ extension UsageMenuCardView.Model { tokenSnapshot: CostUsageTokenSnapshot?, tokenError: String?, account: AccountInfo, + accountLabel: String? = nil, isRefreshing: Bool, lastError: String?, usageBarsShowUsed: Bool, @@ -671,6 +702,7 @@ extension UsageMenuCardView.Model { self.tokenSnapshot = tokenSnapshot self.tokenError = tokenError self.account = account + self.accountLabel = accountLabel self.isRefreshing = isRefreshing self.lastError = lastError self.usageBarsShowUsed = usageBarsShowUsed @@ -693,9 +725,9 @@ extension UsageMenuCardView.Model { metadata: input.metadata) let metrics = Self.metrics(input: input) let usageNotes = Self.usageNotes(input: input) - let creditsText: String? = if input.provider == .openrouter { + let creditsText: String? = if input.provider == .openrouter || input.provider == .codex { nil - } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { + } else if !input.showOptionalCreditsAndExtraUsage { nil } else { Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) @@ -721,6 +753,7 @@ extension UsageMenuCardView.Model { provider: input.provider, providerName: input.metadata.displayName, email: redacted.email, + accountLabel: input.accountLabel, subtitleText: redacted.subtitleText, subtitleStyle: subtitle.style, planText: planText, @@ -770,11 +803,26 @@ extension UsageMenuCardView.Model { account: AccountInfo, metadata: ProviderMetadata) -> String { - if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } - if metadata.usesAccountFallback, - let email = account.email, !email.isEmpty + let baseEmail: String? = if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { + email + } else if metadata.usesAccountFallback, + let email = account.email, !email.isEmpty { - return email + email + } else { + nil + } + + let organization = snapshot?.accountOrganization(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let baseEmail, !baseEmail.isEmpty { + if let organization, !organization.isEmpty { + return "\(baseEmail) — \(organization)" + } + return baseEmail + } + if let organization, !organization.isEmpty { + return organization } return "" } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..fe046d778 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -252,8 +252,16 @@ private struct ProviderDetailInfoGrid: View { labelWidth: self.labelWidth) } - if !email.isEmpty { - ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) + let organization = self.store.snapshot(for: self.provider)?.accountOrganization(for: self.provider) ?? "" + let accountDisplay = if !email.isEmpty, !organization.isEmpty { + "\(email) — \(organization)" + } else if !email.isEmpty { + email + } else { + organization + } + if !accountDisplay.isEmpty { + ProviderDetailInfoRow(label: "Account", value: accountDisplay, labelWidth: self.labelWidth) } if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..c4b471f75 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -266,6 +266,22 @@ struct ProviderSettingsTokenAccountsRowView: View { self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + if let loginTitle = self.descriptor.loginActionTitle, + let runLoginAction = self.descriptor.runLoginAction + { + HStack(spacing: 8) { + Button(loginTitle) { + Task { await runLoginAction() } + } + .buttonStyle(.bordered) + .controlSize(.small) + Text("Opens a dedicated sign-in window and saves the resulting ChatGPT session automatically.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + HStack(spacing: 10) { Button("Open token file") { self.descriptor.openConfigFile() diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index e2dce0a7f..572256032 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -173,6 +173,8 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + loginActionTitle: nil, + runLoginAction: nil, openConfigFile: {}, reloadFromDisk: {}) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a040dafd..eae87b09b 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -187,7 +187,9 @@ struct ProvidersPane: View { }, setActiveIndex: { index in self.settings.setActiveTokenAccountIndex(index, for: provider) + let selectedAccountID = self.settings.selectedTokenAccount(for: provider)?.id Task { @MainActor in + self.store.applyCachedTokenAccountSnapshot(provider: provider, accountID: selectedAccountID) await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) } @@ -209,6 +211,36 @@ struct ProvidersPane: View { } } }, + loginActionTitle: provider == .codex ? "Add via login…" : nil, + runLoginAction: provider == .codex ? { + let runner = ChatGPTAccountLoginRunner(browserDetection: self.store.browserDetection) + let result = await runner.run { _ in } + switch result.outcome { + case let .success(cookieHeader, email, workspaceLabel): + let accounts = self.settings.tokenAccounts(for: .codex) + let baseLabel = CodexAccountLabel.makeBaseLabel( + email: email, + workspace: workspaceLabel, + fallbackIndex: accounts.count + 1) + let label = CodexAccountLabel.uniqueLabel(baseLabel: baseLabel, existingAccounts: accounts) + self.settings.addTokenAccount(provider: .codex, label: label, token: cookieHeader) + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(.codex, allowDisabled: true) + } + case .cancelled: + break + case let .failed(message): + let alert = NSAlert() + alert.messageText = "ChatGPT account login failed" + alert.informativeText = [ + "The sign-in completed, but CodexBar could not validate the captured " + + "ChatGPT session for Codex usage fetching.", + message, + ].joined(separator: "\n\n") + alert.alertStyle = .warning + alert.runModal() + } + } : nil, openConfigFile: { self.settings.openTokenAccountsFile() }, @@ -342,6 +374,7 @@ struct ProvidersPane: View { let weeklyPace = snapshot?.secondary.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } + let accountLabel = provider == .codex ? self.settings.selectedTokenAccount(for: .codex)?.label : nil let input = UsageMenuCardView.Model.Input( provider: provider, metadata: metadata, @@ -353,6 +386,7 @@ struct ProvidersPane: View { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: self.store.accountInfo(), + accountLabel: accountLabel, isRefreshing: self.store.refreshingProviders.contains(provider), lastError: self.store.error(for: provider), usageBarsShowUsed: self.settings.usageBarsShowUsed, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 61aa3a501..4587b78fb 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -44,12 +44,32 @@ struct CodexProviderImplementation: ProviderImplementation { return baseLabel } + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.codexCookieSource == .manual + } + @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + if !context.settings.tokenAccounts(for: .codex).isEmpty { + return ProviderSourceMode.web + } switch context.settings.codexUsageDataSource { - case .auto: .auto - case .oauth: .oauth - case .cli: .cli + case .auto: + return ProviderSourceMode.auto + case .oauth: + return ProviderSourceMode.oauth + case .cli: + return ProviderSourceMode.cli + } + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.codexCookieSource != .manual { + settings.codexCookieSource = .manual } } @@ -146,7 +166,7 @@ struct CodexProviderImplementation: ProviderImplementation { dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, - isVisible: { context.settings.openAIWebAccessEnabled }, + isVisible: { true }, onChange: nil, trailingText: { guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift index b09a3f9b3..8285e4122 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift @@ -5,10 +5,55 @@ import Foundation final class CodexProviderRuntime: ProviderRuntime { let id: UsageProvider = .codex + private struct CredentialState: Equatable { + let cookieSource: ProviderCookieSource + let hasManualCookieHeader: Bool + let hasTokenAccounts: Bool + + var hasManualCredentials: Bool { + self.hasManualCookieHeader || self.hasTokenAccounts + } + + @MainActor + static func capture(from settings: SettingsStore) -> Self { + let manualHeader = settings.codexCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines) + return Self( + cookieSource: settings.codexCookieSource, + hasManualCookieHeader: !manualHeader.isEmpty, + hasTokenAccounts: !settings.tokenAccounts(for: .codex).isEmpty) + } + } + + private var lastCredentialState: CredentialState? + + func settingsDidChange(context: ProviderRuntimeContext) { + let current = CredentialState.capture(from: context.settings) + defer { self.lastCredentialState = current } + + guard let previous = self.lastCredentialState else { return } + guard previous != current else { return } + + let removedAllManualCodexCredentials = + previous.cookieSource == .manual && + previous.hasManualCredentials && + current.cookieSource == .manual && + !current.hasManualCredentials + + let disabledOpenAIWeb = previous.cookieSource.isEnabled && !current.cookieSource.isEnabled + + guard removedAllManualCodexCredentials || disabledOpenAIWeb else { return } + + CookieHeaderCache.clear(provider: .codex) + OpenAIDashboardCacheStore.clear() + context.store.resetOpenAIWebState() + } + func perform(action: ProviderRuntimeAction, context: ProviderRuntimeContext) async { switch action { case let .openAIWebAccessToggled(enabled): guard enabled == false else { return } + CookieHeaderCache.clear(provider: .codex) + OpenAIDashboardCacheStore.clear() context.store.resetOpenAIWebState() case .forceSessionRefresh: break diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 335dbf411..4fc001f63 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -52,7 +52,9 @@ extension SettingsStore { ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride)) + manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), + accountEmail: self.codexSnapshotAccountEmail(tokenOverride: tokenOverride), + workspaceLabel: self.codexSnapshotWorkspaceLabel(tokenOverride: tokenOverride)) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { @@ -94,4 +96,26 @@ extension SettingsStore { if self.tokenAccounts(for: .codex).isEmpty { return fallback } return .manual } + + private func codexSnapshotAccountEmail(tokenOverride: TokenAccountOverride?) -> String? { + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .codex, + settings: self, + override: tokenOverride) + else { + return nil + } + return CodexAccountLabel.parse(account.label).email + } + + private func codexSnapshotWorkspaceLabel(tokenOverride: TokenAccountOverride?) -> String? { + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .codex, + settings: self, + override: tokenOverride) + else { + return nil + } + return CodexAccountLabel.parse(account.label).workspace + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d5a85b8f7..a35e80e2e 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -98,6 +98,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let loginActionTitle: String? + let runLoginAction: (() async -> Void)? let openConfigFile: () -> Void let reloadFromDisk: () -> Void } diff --git a/Sources/CodexBar/SettingsStore+CodexMenuSort.swift b/Sources/CodexBar/SettingsStore+CodexMenuSort.swift new file mode 100644 index 000000000..8a070af38 --- /dev/null +++ b/Sources/CodexBar/SettingsStore+CodexMenuSort.swift @@ -0,0 +1,8 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + func shouldShowCodexMenuSortControl(for provider: UsageProvider) -> Bool { + provider == .codex && self.showAllTokenAccountsInMenu && self.tokenAccounts(for: .codex).count > 1 + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..cdc845540 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -150,6 +150,44 @@ extension SettingsStore { } } + private var codexMenuAccountSortModeRaw: String? { + get { self.defaultsState.codexMenuAccountSortModeRaw } + set { + self.defaultsState.codexMenuAccountSortModeRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "codexMenuAccountSortMode") + } else { + self.userDefaults.removeObject(forKey: "codexMenuAccountSortMode") + } + } + } + + var codexMenuAccountSortMode: CodexMenuAccountSortMode { + get { + switch self.codexMenuAccountSortModeRaw { + case CodexMenuAccountSortMode.accountNameAscending.rawValue: + .accountNameAscending + case CodexMenuAccountSortMode.accountNameDescending.rawValue: + .accountNameDescending + case CodexMenuAccountSortMode.sessionLeftHighToLow.rawValue, + "session-left-low-to-high": + .sessionLeftHighToLow + case CodexMenuAccountSortMode.sessionResetSoonestFirst.rawValue, + "session-reset-latest-first": + .sessionResetSoonestFirst + case CodexMenuAccountSortMode.weeklyLeftHighToLow.rawValue, + "weekly-left-low-to-high": + .weeklyLeftHighToLow + case CodexMenuAccountSortMode.weeklyResetSoonestFirst.rawValue, + "weekly-reset-latest-first": + .weeklyResetSoonestFirst + default: + .default + } + } + set { self.codexMenuAccountSortModeRaw = newValue.rawValue } + } + var historicalTrackingEnabled: Bool { get { self.defaultsState.historicalTrackingEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f01cc49fa..b8d0c925c 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -18,6 +18,7 @@ extension SettingsStore { _ = self.menuBarDisplayMode _ = self.historicalTrackingEnabled _ = self.showAllTokenAccountsInMenu + _ = self.codexMenuAccountSortMode _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled _ = self.hidePersonalInfo diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 1ad4ee00f..383b00736 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -197,6 +197,7 @@ extension SettingsStore { ?? MenuBarDisplayMode.percent.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + let codexMenuAccountSortModeRaw = userDefaults.string(forKey: "codexMenuAccountSortMode") let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] var resolvedPreferences = storedPreferences if resolvedPreferences.isEmpty, @@ -246,6 +247,7 @@ extension SettingsStore { menuBarDisplayModeRaw: menuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, showAllTokenAccountsInMenu: showAllTokenAccountsInMenu, + codexMenuAccountSortModeRaw: codexMenuAccountSortModeRaw, menuBarMetricPreferencesRaw: resolvedPreferences, costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..0d54b9d22 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -17,6 +17,7 @@ struct SettingsDefaultsState { var menuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool var showAllTokenAccountsInMenu: Bool + var codexMenuAccountSortModeRaw: String? var menuBarMetricPreferencesRaw: [String: String] var costUsageEnabled: Bool var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/StatusItemController+CodexMenuSorting.swift b/Sources/CodexBar/StatusItemController+CodexMenuSorting.swift new file mode 100644 index 000000000..06044036a --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CodexMenuSorting.swift @@ -0,0 +1,97 @@ +import AppKit +import CodexBarCore +import Foundation + +extension StatusItemController { + func makeCodexSortControlItem(menu: NSMenu) -> NSMenuItem { + let view = CodexAccountSortControlView( + mode: self.settings.codexMenuAccountSortMode, + width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + onStep: { [weak self, weak menu] delta in + guard let self, let menu else { return } + self.stepCodexMenuSortMode(delta) + let provider = self.menuProvider(for: menu) + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + }) + let item = NSMenuItem() + item.view = view + item.isEnabled = true + return item + } + + func sortedTokenAccountSnapshots( + _ snapshots: [TokenAccountUsageSnapshot], + provider: UsageProvider) -> [TokenAccountUsageSnapshot] + { + guard provider == .codex, snapshots.count > 1 else { return snapshots } + let mode = self.settings.codexMenuAccountSortMode + return snapshots.sorted { lhs, rhs in + self.compareTokenAccountSnapshots(lhs, rhs, mode: mode) + } + } + + func compareTokenAccountSnapshots( + _ lhs: TokenAccountUsageSnapshot, + _ rhs: TokenAccountUsageSnapshot, + mode: CodexMenuAccountSortMode) -> Bool + { + let lhsName = lhs.account.label.trimmingCharacters(in: .whitespacesAndNewlines) + let rhsName = rhs.account.label.trimmingCharacters(in: .whitespacesAndNewlines) + + func fallbackNameAscending() -> Bool { + lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + } + + func compareOptional(_ lhsValue: T?, _ rhsValue: T?, ascending: Bool) -> Bool { + switch (lhsValue, rhsValue) { + case let (lhs?, rhs?): + if lhs != rhs { + return ascending ? lhs < rhs : lhs > rhs + } + return fallbackNameAscending() + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return fallbackNameAscending() + } + } + + switch mode { + case .accountNameAscending: + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + case .accountNameDescending: + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedDescending + case .sessionLeftHighToLow: + return compareOptional( + lhs.snapshot?.primary?.remainingPercent, + rhs.snapshot?.primary?.remainingPercent, + ascending: false) + case .sessionResetSoonestFirst: + return compareOptional(lhs.snapshot?.primary?.resetsAt, rhs.snapshot?.primary?.resetsAt, ascending: true) + case .weeklyLeftHighToLow: + return compareOptional( + lhs.snapshot?.secondary?.remainingPercent, + rhs.snapshot?.secondary?.remainingPercent, + ascending: false) + case .weeklyResetSoonestFirst: + return compareOptional( + lhs.snapshot?.secondary?.resetsAt, + rhs.snapshot?.secondary?.resetsAt, + ascending: true) + } + } + + func stepCodexMenuSortMode(_ delta: Int) { + let modes = CodexMenuAccountSortMode.allCases + guard let currentIndex = modes.firstIndex(of: self.settings.codexMenuAccountSortMode), !modes.isEmpty else { + self.settings.codexMenuAccountSortMode = .default + return + } + let count = modes.count + let nextIndex = (currentIndex + delta % count + count) % count + self.settings.codexMenuAccountSortMode = modes[nextIndex] + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1d7c6e35d..63fc294d8 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -15,7 +15,7 @@ extension ProviderSwitcherSelection { } } -private struct OverviewMenuCardRowView: View { +struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model let width: CGFloat @@ -44,16 +44,10 @@ private struct OverviewMenuCardRowView: View { // MARK: - NSMenu construction extension StatusItemController { - private static let menuCardBaseWidth: CGFloat = 310 - private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - private static let overviewRowIdentifierPrefix = "overviewRow-" + static let menuCardBaseWidth: CGFloat = 310 + static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit + static let overviewRowIdentifierPrefix = "overviewRow-" private static let menuOpenRefreshDelay: Duration = .seconds(1.2) - private struct OpenAIWebMenuItems { - let hasUsageBreakdown: Bool - let hasCreditsHistory: Bool - let hasCostHistory: Bool - } - private struct TokenAccountMenuDisplay { let provider: UsageProvider let accounts: [ProviderTokenAccount] @@ -63,7 +57,7 @@ extension StatusItemController { let showSwitcher: Bool } - private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { + func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu return Self.menuCardBaseWidth } @@ -146,7 +140,7 @@ extension StatusItemController { } } - private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -218,6 +212,7 @@ extension StatusItemController { self.lastSwitcherIncludesOverview = includesOverview } self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + self.addCodexSortControlIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, @@ -294,13 +289,6 @@ extension StatusItemController { self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) } - private struct OpenAIWebContext { - let hasUsageBreakdown: Bool - let hasCreditsHistory: Bool - let hasCostHistory: Bool - let hasOpenAIWebMenuItems: Bool - } - private struct MenuCardContext { let currentProvider: UsageProvider let selectedProvider: UsageProvider? @@ -309,27 +297,6 @@ extension StatusItemController { let openAIContext: OpenAIWebContext } - private func openAIWebContext( - currentProvider: UsageProvider, - showAllTokenAccounts: Bool) -> OpenAIWebContext - { - let dashboard = self.store.openAIDashboard - let openAIWebEligible = currentProvider == .codex && - self.store.openAIDashboardRequiresLogin == false && - dashboard != nil - let hasCreditsHistory = openAIWebEligible && !(dashboard?.dailyBreakdown ?? []).isEmpty - let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty - let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && - (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) - let hasOpenAIWebMenuItems = !showAllTokenAccounts && - (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) - return OpenAIWebContext( - hasUsageBreakdown: hasUsageBreakdown, - hasCreditsHistory: hasCreditsHistory, - hasCostHistory: hasCostHistory, - hasOpenAIWebMenuItems: hasOpenAIWebMenuItems) - } - private func addProviderSwitcherIfNeeded( to menu: NSMenu, enabledProviders: [UsageProvider], @@ -353,88 +320,60 @@ extension StatusItemController { menu.addItem(.separator()) } - @discardableResult - private func addOverviewRows( - to menu: NSMenu, - enabledProviders: [UsageProvider], - menuWidth: CGFloat) -> Bool - { - let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( - activeProviders: enabledProviders) - let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders - .compactMap { provider in - guard let model = self.menuCardModel(for: provider) else { return nil } - return (provider: provider, model: model) - } - guard !rows.isEmpty else { return false } - - for (index, row) in rows.enumerated() { - let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" - let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, width: menuWidth), - id: identifier, - width: menuWidth, - onClick: { [weak self, weak menu] in - guard let self, let menu else { return } - self.selectOverviewProvider(row.provider, menu: menu) - }) - // Keep menu item action wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) - menu.addItem(item) - if index < rows.count - 1 { - menu.addItem(.separator()) - } - } - return true - } - - private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { - let resolvedProviders = self.settings.resolvedMergedOverviewProviders( - activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) - let message = if resolvedProviders.isEmpty { - "No providers selected for Overview." - } else { - "No overview data available." + private func addCodexSortControlIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) { + guard let display, + display.provider == .codex, + display.showAll, + self.settings.shouldShowCodexMenuSortControl(for: display.provider) + else { + return } - let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") - item.isEnabled = false - item.representedObject = "overviewEmptyState" + let item = self.makeCodexSortControlItem(menu: menu) menu.addItem(item) + menu.addItem(.separator()) } private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + // When Codex is configured to show all token accounts, the UI renders one menu card + // per account-scoped snapshot produced by UsageStore instead of only showing the + // single provider-level snapshot. if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { - let accountSnapshots = tokenAccountDisplay.snapshots + let accountSnapshots = self.sortedTokenAccountSnapshots( + tokenAccountDisplay.snapshots, + provider: tokenAccountDisplay.provider) let cards = accountSnapshots.isEmpty ? [] : accountSnapshots.compactMap { accountSnapshot in self.menuCardModel( for: context.currentProvider, snapshotOverride: accountSnapshot.snapshot, - errorOverride: accountSnapshot.error) + errorOverride: accountSnapshot.error, + accountLabelOverride: accountSnapshot.account.label, + sourceLabelOverride: accountSnapshot.sourceLabel, + isAccountScopedOverride: true) } - if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + + if cards.isEmpty, let fallback = self.menuCardModel(for: context.currentProvider) { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), - id: "menuCard", + UsageMenuCardView(model: fallback, width: context.menuWidth), + id: "menuCard-fallback", width: context.menuWidth)) menu.addItem(.separator()) - } else { - for (index, model) in cards.enumerated() { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), - id: "menuCard-\(index)", - width: context.menuWidth)) - if index < cards.count - 1 { - menu.addItem(.separator()) - } - } - if !cards.isEmpty { + return false + } + + for (index, model) in cards.enumerated() { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard-\(index)", + width: context.menuWidth)) + if index < cards.count - 1 { menu.addItem(.separator()) } } + if !cards.isEmpty { + menu.addItem(.separator()) + } return false } @@ -464,28 +403,6 @@ extension StatusItemController { return false } - private func addOpenAIWebItemsIfNeeded( - to menu: NSMenu, - currentProvider: UsageProvider, - context: OpenAIWebContext, - addedOpenAIWebItems: Bool) - { - guard context.hasOpenAIWebMenuItems else { return } - if !addedOpenAIWebItems { - // Only show these when we actually have additional data. - if context.hasUsageBreakdown { - _ = self.addUsageBreakdownSubmenu(to: menu) - } - if context.hasCreditsHistory { - _ = self.addCreditsHistorySubmenu(to: menu) - } - if context.hasCostHistory { - _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) - } - } - menu.addItem(.separator()) - } - private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { let actionableSections = sections.filter { section in section.entries.contains { entry in @@ -646,6 +563,8 @@ extension StatusItemController { onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) + let selectedAccountID = self.settings.selectedTokenAccount(for: display.provider)?.id + self.store.applyCachedTokenAccountSnapshot(provider: display.provider, accountID: selectedAccountID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() @@ -661,7 +580,7 @@ extension StatusItemController { return item } - private func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { + func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { let enabled = enabledProviders ?? self.store.enabledProvidersForDisplay() if enabled.isEmpty { return .codex } if let selected = self.selectedMenuProvider, enabled.contains(selected) { @@ -672,22 +591,6 @@ extension StatusItemController { return enabled.first(where: { self.store.isProviderAvailable($0) }) ?? enabled.first } - private func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { - !self.settings.resolvedMergedOverviewProviders( - activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders).isEmpty - } - - private func resolvedSwitcherSelection( - enabledProviders: [UsageProvider], - includesOverview: Bool) -> ProviderSwitcherSelection - { - if includesOverview, self.settings.mergedMenuLastSelectedWasOverview { - return .overview - } - return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) - } - private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) @@ -709,7 +612,7 @@ extension StatusItemController { return self.menuVersions[key] != self.menuContentVersion } - private func markMenuFresh(_ menu: NSMenu) { + func markMenuFresh(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion } @@ -740,7 +643,7 @@ extension StatusItemController { } } - private func menuProvider(for menu: NSMenu) -> UsageProvider? { + func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() } @@ -820,7 +723,7 @@ extension StatusItemController { } } - private func makeMenuCardItem( + func makeMenuCardItem( _ view: some View, id: String, width: CGFloat, @@ -1070,7 +973,7 @@ extension StatusItemController { var isHighlighted = false } - private final class MenuHostingView: NSHostingView { + final class MenuHostingView: NSHostingView { override var allowsVibrancy: Bool { true } @@ -1184,51 +1087,7 @@ extension StatusItemController { return item } - @discardableResult - private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { - guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) - return true - } - - @discardableResult - private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { - guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) - return true - } - - @discardableResult - private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { - guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) - return true - } - - private func makeUsageSubmenu( - provider: UsageProvider, - snapshot: UsageSnapshot?, - webItems: OpenAIWebMenuItems) -> NSMenu? - { - if provider == .codex, webItems.hasUsageBreakdown { - return self.makeUsageBreakdownSubmenu() - } - if provider == .zai { - return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) - } - return nil - } - - private func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { + func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } guard !timeLimit.usageDetails.isEmpty else { return nil } @@ -1264,107 +1123,6 @@ extension StatusItemController { return submenu } - private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu - } - - private func makeCreditsHistorySubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - let width = Self.menuCardBaseWidth - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } - guard !tokenSnapshot.daily.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CostHistoryChartMenuView( - provider: provider, - daily: tokenSnapshot.daily, - totalCostUSD: tokenSnapshot.last30DaysCostUSD, - width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu - } - private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ "usageBreakdownChart", @@ -1401,29 +1159,33 @@ extension StatusItemController { } } - private func menuCardModel( + func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, - errorOverride: String? = nil) -> UsageMenuCardView.Model? + errorOverride: String? = nil, + accountLabelOverride: String? = nil, + sourceLabelOverride: String? = nil, + isAccountScopedOverride: Bool = false) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) - let snapshot = snapshotOverride ?? self.store.snapshot(for: target) + let useGlobalSnapshot = !isAccountScopedOverride && snapshotOverride == nil + let snapshot = snapshotOverride ?? (useGlobalSnapshot ? self.store.snapshot(for: target) : nil) let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? - if target == .codex, snapshotOverride == nil { + if target == .codex, useGlobalSnapshot { credits = self.store.credits creditsError = self.store.lastCreditsError dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) - } else if target == .claude || target == .vertexai, snapshotOverride == nil { + } else if target == .claude || target == .vertexai, useGlobalSnapshot { credits = nil creditsError = nil dashboard = nil @@ -1439,12 +1201,19 @@ extension StatusItemController { tokenError = nil } - let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil + let sourceLabel = sourceLabelOverride ?? (useGlobalSnapshot ? self.store.sourceLabel(for: target) : nil) let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto let now = Date() let weeklyPace = snapshot?.secondary.flatMap { window in self.store.weeklyPace(provider: target, window: window, now: now) } + let resolvedLastError: String? = if isAccountScopedOverride { + errorOverride + } else if snapshotOverride != nil { + errorOverride + } else { + errorOverride ?? self.store.error(for: target) + } let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, @@ -1456,8 +1225,9 @@ extension StatusItemController { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: self.account, + accountLabel: accountLabelOverride, isRefreshing: self.store.isRefreshing, - lastError: errorOverride ?? self.store.error(for: target), + lastError: resolvedLastError, usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), @@ -1474,33 +1244,6 @@ extension StatusItemController { _ = sender } - @objc private func selectOverviewProvider(_ sender: NSMenuItem) { - guard let represented = sender.representedObject as? String, - represented.hasPrefix(Self.overviewRowIdentifierPrefix) - else { - return - } - let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) - guard let provider = UsageProvider(rawValue: rawProvider), - let menu = sender.menu - else { - return - } - - self.selectOverviewProvider(provider, menu: menu) - } - - private func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { - if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } - self.settings.mergedMenuLastSelectedWasOverview = false - self.lastMergedSwitcherSelection = nil - self.selectedMenuProvider = provider - self.lastMenuProvider = provider - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) - } - private func applySubtitle(_ subtitle: String, to item: NSMenuItem, title: String) { if #available(macOS 14.4, *) { // NSMenuItem.subtitle is only available on macOS 14.4+. diff --git a/Sources/CodexBar/StatusItemController+OpenAIWebMenu.swift b/Sources/CodexBar/StatusItemController+OpenAIWebMenu.swift new file mode 100644 index 000000000..4b75c566b --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OpenAIWebMenu.swift @@ -0,0 +1,202 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + struct OpenAIWebMenuItems { + let hasUsageBreakdown: Bool + let hasCreditsHistory: Bool + let hasCostHistory: Bool + } + + struct OpenAIWebContext { + let hasUsageBreakdown: Bool + let hasCreditsHistory: Bool + let hasCostHistory: Bool + let hasOpenAIWebMenuItems: Bool + } + + func openAIWebContext( + currentProvider: UsageProvider, + showAllTokenAccounts: Bool) -> OpenAIWebContext + { + let dashboard = self.store.openAIDashboard + let openAIWebEligible = currentProvider == .codex && + self.store.openAIDashboardRequiresLogin == false && + dashboard != nil + let hasCreditsHistory = openAIWebEligible && !(dashboard?.dailyBreakdown ?? []).isEmpty + let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty + let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && + (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) + let hasOpenAIWebMenuItems = !showAllTokenAccounts && + (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) + return OpenAIWebContext( + hasUsageBreakdown: hasUsageBreakdown, + hasCreditsHistory: hasCreditsHistory, + hasCostHistory: hasCostHistory, + hasOpenAIWebMenuItems: hasOpenAIWebMenuItems) + } + + func addOpenAIWebItemsIfNeeded( + to menu: NSMenu, + currentProvider: UsageProvider, + context: OpenAIWebContext, + addedOpenAIWebItems: Bool) + { + guard context.hasOpenAIWebMenuItems else { return } + if !addedOpenAIWebItems { + if context.hasUsageBreakdown { + _ = self.addUsageBreakdownSubmenu(to: menu) + } + if context.hasCreditsHistory { + _ = self.addCreditsHistorySubmenu(to: menu) + } + if context.hasCostHistory { + _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) + } + } + menu.addItem(.separator()) + } + + @discardableResult + private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { + guard let submenu = self.makeCreditsHistorySubmenu() else { return false } + let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) + return true + } + + @discardableResult + private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { + guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } + let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) + return true + } + + @discardableResult + private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } + let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) + return true + } + + func makeUsageSubmenu( + provider: UsageProvider, + snapshot: UsageSnapshot?, + webItems: OpenAIWebMenuItems) -> NSMenu? + { + if provider == .codex, webItems.hasUsageBreakdown { + return self.makeUsageBreakdownSubmenu() + } + if provider == .zai { + return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) + } + return nil + } + + private func makeUsageBreakdownSubmenu() -> NSMenu? { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + let width = Self.menuCardBaseWidth + guard !breakdown.isEmpty else { return nil } + + if !Self.menuCardRenderingEnabled { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = "usageBreakdownChart" + submenu.addItem(chartItem) + return submenu + } + + let submenu = NSMenu() + submenu.delegate = self + let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "usageBreakdownChart" + submenu.addItem(chartItem) + return submenu + } + + func makeCreditsHistorySubmenu() -> NSMenu? { + let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] + let width = Self.menuCardBaseWidth + guard !breakdown.isEmpty else { return nil } + + if !Self.menuCardRenderingEnabled { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = "creditsHistoryChart" + submenu.addItem(chartItem) + return submenu + } + + let submenu = NSMenu() + submenu.delegate = self + let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "creditsHistoryChart" + submenu.addItem(chartItem) + return submenu + } + + func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { + guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + let width = Self.menuCardBaseWidth + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } + guard !tokenSnapshot.daily.isEmpty else { return nil } + + if !Self.menuCardRenderingEnabled { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = "costHistoryChart" + submenu.addItem(chartItem) + return submenu + } + + let submenu = NSMenu() + submenu.delegate = self + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "costHistoryChart" + submenu.addItem(chartItem) + return submenu + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewMenu.swift b/Sources/CodexBar/StatusItemController+OverviewMenu.swift new file mode 100644 index 000000000..5099eb4f4 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewMenu.swift @@ -0,0 +1,97 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + @discardableResult + func addOverviewRows( + to menu: NSMenu, + enabledProviders: [UsageProvider], + menuWidth: CGFloat) -> Bool + { + let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( + activeProviders: enabledProviders) + let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders + .compactMap { provider in + guard let model = self.menuCardModel(for: provider) else { return nil } + return (provider: provider, model: model) + } + guard !rows.isEmpty else { return false } + + for (index, row) in rows.enumerated() { + let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" + let item = self.makeMenuCardItem( + OverviewMenuCardRowView(model: row.model, width: menuWidth), + id: identifier, + width: menuWidth, + onClick: { [weak self, weak menu] in + guard let self, let menu else { return } + self.selectOverviewProvider(row.provider, menu: menu) + }) + item.target = self + item.action = #selector(self.selectOverviewProvider(_:)) + menu.addItem(item) + if index < rows.count - 1 { + menu.addItem(.separator()) + } + } + return true + } + + func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { + let resolvedProviders = self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: Self.maxOverviewProviders) + let message = if resolvedProviders.isEmpty { + "No providers selected for Overview." + } else { + "No overview data available." + } + let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") + item.isEnabled = false + item.representedObject = "overviewEmptyState" + menu.addItem(item) + } + + func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { + !self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: Self.maxOverviewProviders).isEmpty + } + + func resolvedSwitcherSelection( + enabledProviders: [UsageProvider], + includesOverview: Bool) -> ProviderSwitcherSelection + { + if includesOverview, self.settings.mergedMenuLastSelectedWasOverview { + return .overview + } + return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) + } + + @objc func selectOverviewProvider(_ sender: NSMenuItem) { + guard let represented = sender.representedObject as? String, + represented.hasPrefix(Self.overviewRowIdentifierPrefix) + else { + return + } + let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawProvider), + let menu = sender.menu + else { + return + } + + self.selectOverviewProvider(provider, menu: menu) + } + + func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { + if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } + self.settings.mergedMenuLastSelectedWasOverview = false + self.lastMergedSwitcherSelection = nil + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenAccountHelpers.swift b/Sources/CodexBar/UsageStore+TokenAccountHelpers.swift new file mode 100644 index 000000000..8342ecd80 --- /dev/null +++ b/Sources/CodexBar/UsageStore+TokenAccountHelpers.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func limitedTokenAccounts( + _ accounts: [ProviderTokenAccount], + selected: ProviderTokenAccount?) -> [ProviderTokenAccount] + { + let limit = 6 + if accounts.count <= limit { return accounts } + var limited = Array(accounts.prefix(limit)) + if let selected, !limited.contains(where: { $0.id == selected.id }) { + limited.removeLast() + limited.append(selected) + } + return limited + } + + func prioritizedTokenAccounts( + _ accounts: [ProviderTokenAccount], + selected: ProviderTokenAccount?) -> [ProviderTokenAccount] + { + guard let selected, + let selectedIndex = accounts.firstIndex(where: { $0.id == selected.id }) + else { + return accounts + } + var prioritized = accounts + let selectedAccount = prioritized.remove(at: selectedIndex) + prioritized.insert(selectedAccount, at: 0) + return prioritized + } + + struct ResolvedAccountOutcome { + let snapshot: TokenAccountUsageSnapshot + let usage: UsageSnapshot? + } + + func fetchTokenAccountSnapshotsInBatches( + provider: UsageProvider, + accounts: [ProviderTokenAccount], + maxConcurrent: Int) async -> [UUID: TokenAccountUsageSnapshot] + { + guard !accounts.isEmpty else { return [:] } + let batchSize = max(1, maxConcurrent) + var collected: [UUID: TokenAccountUsageSnapshot] = [:] + + for start in stride(from: 0, to: accounts.count, by: batchSize) { + let batch = Array(accounts[start.. ResolvedAccountOutcome + { + switch outcome.result { + case let .success(result): + let scoped = result.usage.scoped(to: provider) + let labeled = self.applyAccountLabel(scoped, provider: provider, account: account) + let snapshot = TokenAccountUsageSnapshot( + account: account, + snapshot: labeled, + error: nil, + sourceLabel: result.sourceLabel) + return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) + case let .failure(error): + let snapshot = TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: error.localizedDescription, + sourceLabel: nil) + return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) + } + } +} diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index f8cfd2f87..73d41ce63 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -18,6 +18,12 @@ struct TokenAccountUsageSnapshot: Identifiable { } extension UsageStore { + /// Codex multi-account support is built by reusing the shared token-account pipeline: + /// fetch once per stored account, keep the resulting snapshots separate, and let the + /// menu render multiple account cards at the same time instead of collapsing Codex + /// into a single usage view. + private static let tokenAccountFetchConcurrencyLimit = 5 + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) @@ -32,46 +38,36 @@ extension UsageStore { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first - var snapshots: [TokenAccountUsageSnapshot] = [] - var selectedOutcome: ProviderFetchOutcome? - var selectedSnapshot: UsageSnapshot? - for account in limitedAccounts { - let override = TokenAccountOverride(provider: provider, account: account) - let outcome = await self.fetchOutcome(provider: provider, override: override) - let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) - snapshots.append(resolved.snapshot) - if account.id == effectiveSelected?.id { - selectedOutcome = outcome - selectedSnapshot = resolved.usage - } - } - - await MainActor.run { - self.accountSnapshots[provider] = snapshots - } + var snapshotsByID: [UUID: TokenAccountUsageSnapshot] = [:] - if let selectedOutcome { + if let effectiveSelected { + let override = TokenAccountOverride(provider: provider, account: effectiveSelected) + let outcome = await self.fetchOutcome(provider: provider, override: override) + let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: effectiveSelected) + snapshotsByID[effectiveSelected.id] = resolved.snapshot await self.applySelectedOutcome( - selectedOutcome, + outcome, provider: provider, account: effectiveSelected, - fallbackSnapshot: selectedSnapshot) + fallbackSnapshot: resolved.usage) } - } - func limitedTokenAccounts( - _ accounts: [ProviderTokenAccount], - selected: ProviderTokenAccount?) -> [ProviderTokenAccount] - { - let limit = 6 - if accounts.count <= limit { return accounts } - var limited = Array(accounts.prefix(limit)) - if let selected, !limited.contains(where: { $0.id == selected.id }) { - limited.removeLast() - limited.append(selected) + let remainingAccounts = limitedAccounts.filter { $0.id != effectiveSelected?.id } + if !remainingAccounts.isEmpty { + let additionalSnapshots = await self.fetchTokenAccountSnapshotsInBatches( + provider: provider, + accounts: remainingAccounts, + maxConcurrent: Self.tokenAccountFetchConcurrencyLimit) + for (accountID, snapshot) in additionalSnapshots { + snapshotsByID[accountID] = snapshot + } + } + + let orderedSnapshots = limitedAccounts.compactMap { snapshotsByID[$0.id] } + await MainActor.run { + self.accountSnapshots[provider] = orderedSnapshots } - return limited } func fetchOutcome( @@ -79,7 +75,11 @@ extension UsageStore { override: TokenAccountOverride?) async -> ProviderFetchOutcome { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) - let sourceMode = self.sourceMode(for: provider) + let sourceMode: ProviderSourceMode = if provider == .codex, override != nil { + .web + } else { + self.sourceMode(for: provider) + } let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override) let env = ProviderRegistry.makeEnvironment( base: ProcessInfo.processInfo.environment, @@ -108,33 +108,22 @@ extension UsageStore { ?? .auto } - private struct ResolvedAccountOutcome { - let snapshot: TokenAccountUsageSnapshot - let usage: UsageSnapshot? - } + @MainActor + func applyCachedTokenAccountSnapshot(provider: UsageProvider, accountID: UUID?) { + guard let accountID, + let cached = self.accountSnapshots[provider]?.first(where: { $0.account.id == accountID }) + else { + return + } - private func resolveAccountOutcome( - _ outcome: ProviderFetchOutcome, - provider: UsageProvider, - account: ProviderTokenAccount) -> ResolvedAccountOutcome - { - switch outcome.result { - case let .success(result): - let scoped = result.usage.scoped(to: provider) - let labeled = self.applyAccountLabel(scoped, provider: provider, account: account) - let snapshot = TokenAccountUsageSnapshot( - account: account, - snapshot: labeled, - error: nil, - sourceLabel: result.sourceLabel) - return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) - case let .failure(error): - let snapshot = TokenAccountUsageSnapshot( - account: account, - snapshot: nil, - error: error.localizedDescription, - sourceLabel: nil) - return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) + if let snapshot = cached.snapshot { + self.handleSessionQuotaTransition(provider: provider, snapshot: snapshot) + self.snapshots[provider] = snapshot + self.lastSourceLabels[provider] = cached.sourceLabel + self.errors[provider] = nil + self.failureGates[provider]?.recordSuccess() + } else if let error = cached.error, !error.isEmpty { + self.errors[provider] = error } } @@ -185,12 +174,17 @@ extension UsageStore { let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty else { return snapshot } let existing = snapshot.identity(for: provider) + + let parsed = CodexAccountLabel.parse(label) + let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedEmail = (email?.isEmpty ?? true) ? label : email + let organization = existing?.accountOrganization?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedEmail = (email?.isEmpty ?? true) ? (parsed.email ?? label) : email + let resolvedOrganization = (organization?.isEmpty ?? true) ? parsed.workspace : organization let identity = ProviderIdentitySnapshot( providerID: provider, accountEmail: resolvedEmail, - accountOrganization: existing?.accountOrganization, + accountOrganization: resolvedOrganization, loginMethod: existing?.loginMethod) return snapshot.withIdentity(identity) } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 937b37aa0..8c91489d9 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -80,13 +80,18 @@ struct TokenAccountCLIContext { switch provider { case .codex: + let parsedAccount = account.map { CodexAccountLabel.parse($0.label) } + let accountEmail = parsedAccount?.email + let workspaceLabel = parsedAccount?.workspace let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( codex: ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: .auto, cookieSource: cookieSource, - manualCookieHeader: cookieHeader)) + manualCookieHeader: cookieHeader, + accountEmail: accountEmail, + workspaceLabel: workspaceLabel)) case .claude: let routing = self.claudeCredentialRouting(account: account, config: config) let claudeSource: ClaudeUsageDataSource = routing.isOAuth ? .oauth : .auto diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index cf3deda4a..990810033 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -78,6 +78,20 @@ public struct OpenAIDashboardBrowserCookieImporter { var accessDeniedHints: [String] = [] } + private struct SourceAttemptContext { + let targetEmail: String? + let targetWorkspaceLabel: String? + let allowAnyAccount: Bool + let log: (String) -> Void + } + + private struct CandidateAttemptContext { + let targetEmail: String? + let targetWorkspaceLabel: String? + let allowAnyAccount: Bool + let log: (String) -> Void + } + private static let cookieDomains = ["chatgpt.com", "openai.com"] private static let cookieClient = BrowserCookieClient() private static let cookieImportOrder: BrowserCookieImportOrder = @@ -93,6 +107,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail targetEmail: String?, + intoWorkspaceLabel targetWorkspaceLabel: String? = nil, allowAnyAccount: Bool = false, logger: ((String) -> Void)? = nil) async throws -> ImportResult { @@ -122,6 +137,7 @@ public struct OpenAIDashboardBrowserCookieImporter { return try await self.importManualCookies( cookieHeader: cached.cookieHeader, intoAccountEmail: normalizedTarget, + intoWorkspaceLabel: targetWorkspaceLabel, allowAnyAccount: allowAnyAccount, logger: log) } catch let error as ImportError { @@ -138,14 +154,13 @@ public struct OpenAIDashboardBrowserCookieImporter { // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: self.browserDetection) + let sourceContext = SourceAttemptContext( + targetEmail: normalizedTarget, + targetWorkspaceLabel: targetWorkspaceLabel, + allowAnyAccount: allowAnyAccount, + log: log) for browserSource in installedBrowsers { - if let match = await self.trySource( - browserSource, - targetEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - log: log, - diagnostics: &diagnostics) - { + if let match = await self.trySource(browserSource, context: sourceContext, diagnostics: &diagnostics) { return match } } @@ -176,6 +191,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importManualCookies( cookieHeader: String, intoAccountEmail targetEmail: String?, + intoWorkspaceLabel targetWorkspaceLabel: String? = nil, allowAnyAccount: Bool = false, logger: ((String) -> Void)? = nil) async throws -> ImportResult { @@ -201,9 +217,17 @@ public struct OpenAIDashboardBrowserCookieImporter { log: log) { case let .match(_, signedInEmail): - return try await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) + return try await self.persist( + candidate: candidate, + targetEmail: signedInEmail, + workspaceLabel: targetWorkspaceLabel, + logger: log) case let .loggedIn(_, signedInEmail): - return try await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) + return try await self.persist( + candidate: candidate, + targetEmail: signedInEmail, + workspaceLabel: targetWorkspaceLabel, + logger: log) case let .mismatch(_, signedInEmail): throw ImportError.noMatchingAccount(found: [FoundAccount(sourceLabel: "Manual", email: signedInEmail)]) case .unknown: @@ -218,6 +242,7 @@ public struct OpenAIDashboardBrowserCookieImporter { private func trySafari( targetEmail: String?, + targetWorkspaceLabel: String?, allowAnyAccount: Bool, log: @escaping (String) -> Void, diagnostics: inout ImportDiagnostics) async -> ImportResult? @@ -243,13 +268,12 @@ public struct OpenAIDashboardBrowserCookieImporter { diagnostics.foundAnyCookies = true log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))") let candidate = Candidate(label: source.label, cookies: cookies) - if let match = await self.applyCandidate( - candidate, + let context = CandidateAttemptContext( targetEmail: targetEmail, + targetWorkspaceLabel: targetWorkspaceLabel, allowAnyAccount: allowAnyAccount, - log: log, - diagnostics: &diagnostics) - { + log: log) + if let match = await self.applyCandidate(candidate, context: context, diagnostics: &diagnostics) { return match } } @@ -269,6 +293,7 @@ public struct OpenAIDashboardBrowserCookieImporter { private func tryChrome( targetEmail: String?, + targetWorkspaceLabel: String?, allowAnyAccount: Bool, log: @escaping (String) -> Void, diagnostics: inout ImportDiagnostics) async -> ImportResult? @@ -288,13 +313,12 @@ public struct OpenAIDashboardBrowserCookieImporter { diagnostics.foundAnyCookies = true log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))") let candidate = Candidate(label: source.label, cookies: cookies) - if let match = await self.applyCandidate( - candidate, + let context = CandidateAttemptContext( targetEmail: targetEmail, + targetWorkspaceLabel: targetWorkspaceLabel, allowAnyAccount: allowAnyAccount, - log: log, - diagnostics: &diagnostics) - { + log: log) + if let match = await self.applyCandidate(candidate, context: context, diagnostics: &diagnostics) { return match } } @@ -314,6 +338,7 @@ public struct OpenAIDashboardBrowserCookieImporter { private func tryFirefox( targetEmail: String?, + targetWorkspaceLabel: String?, allowAnyAccount: Bool, log: @escaping (String) -> Void, diagnostics: inout ImportDiagnostics) async -> ImportResult? @@ -333,13 +358,12 @@ public struct OpenAIDashboardBrowserCookieImporter { diagnostics.foundAnyCookies = true log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))") let candidate = Candidate(label: source.label, cookies: cookies) - if let match = await self.applyCandidate( - candidate, + let context = CandidateAttemptContext( targetEmail: targetEmail, + targetWorkspaceLabel: targetWorkspaceLabel, allowAnyAccount: allowAnyAccount, - log: log, - diagnostics: &diagnostics) - { + log: log) + if let match = await self.applyCandidate(candidate, context: context, diagnostics: &diagnostics) { return match } } @@ -359,29 +383,30 @@ public struct OpenAIDashboardBrowserCookieImporter { private func trySource( _ source: Browser, - targetEmail: String?, - allowAnyAccount: Bool, - log: @escaping (String) -> Void, + context: SourceAttemptContext, diagnostics: inout ImportDiagnostics) async -> ImportResult? { switch source { case .safari: await self.trySafari( - targetEmail: targetEmail, - allowAnyAccount: allowAnyAccount, - log: log, + targetEmail: context.targetEmail, + targetWorkspaceLabel: context.targetWorkspaceLabel, + allowAnyAccount: context.allowAnyAccount, + log: context.log, diagnostics: &diagnostics) case .chrome: await self.tryChrome( - targetEmail: targetEmail, - allowAnyAccount: allowAnyAccount, - log: log, + targetEmail: context.targetEmail, + targetWorkspaceLabel: context.targetWorkspaceLabel, + allowAnyAccount: context.allowAnyAccount, + log: context.log, diagnostics: &diagnostics) case .firefox: await self.tryFirefox( - targetEmail: targetEmail, - allowAnyAccount: allowAnyAccount, - log: log, + targetEmail: context.targetEmail, + targetWorkspaceLabel: context.targetWorkspaceLabel, + allowAnyAccount: context.allowAnyAccount, + log: context.log, diagnostics: &diagnostics) default: nil @@ -390,21 +415,24 @@ public struct OpenAIDashboardBrowserCookieImporter { private func applyCandidate( _ candidate: Candidate, - targetEmail: String?, - allowAnyAccount: Bool, - log: @escaping (String) -> Void, + context: CandidateAttemptContext, diagnostics: inout ImportDiagnostics) async -> ImportResult? { switch await self.evaluateCandidate( candidate, - targetEmail: targetEmail, - allowAnyAccount: allowAnyAccount, - log: log) + targetEmail: context.targetEmail, + allowAnyAccount: context.allowAnyAccount, + log: context.log) { case let .match(candidate, signedInEmail): - log("Selected \(candidate.label) (matches Codex: \(signedInEmail))") - guard let targetEmail else { return nil } - if let result = try? await self.persist(candidate: candidate, targetEmail: targetEmail, logger: log) { + context.log("Selected \(candidate.label) (matches Codex: \(signedInEmail))") + guard let targetEmail = context.targetEmail else { return nil } + if let result = try? await self.persist( + candidate: candidate, + targetEmail: targetEmail, + workspaceLabel: context.targetWorkspaceLabel, + logger: context.log) + { self.cacheCookies(candidate: candidate) return result } @@ -413,20 +441,25 @@ public struct OpenAIDashboardBrowserCookieImporter { await self.handleMismatch( candidate: candidate, signedInEmail: signedInEmail, - log: log, + log: context.log, diagnostics: &diagnostics) return nil case let .loggedIn(candidate, signedInEmail): - log("Selected \(candidate.label) (signed in: \(signedInEmail))") - if let result = try? await self.persist(candidate: candidate, targetEmail: signedInEmail, logger: log) { + context.log("Selected \(candidate.label) (signed in: \(signedInEmail))") + if let result = try? await self.persist( + candidate: candidate, + targetEmail: signedInEmail, + workspaceLabel: context.targetWorkspaceLabel, + logger: context.log) + { self.cacheCookies(candidate: candidate) return result } return nil case .unknown: - if allowAnyAccount { - log("Selected \(candidate.label) (signed in: unknown)") - if let result = try? await self.persistToDefaultStore(candidate: candidate, logger: log) { + if context.allowAnyAccount { + context.log("Selected \(candidate.label) (signed in: unknown)") + if let result = try? await self.persistToDefaultStore(candidate: candidate, logger: context.log) { self.cacheCookies(candidate: candidate) return result } @@ -522,7 +555,11 @@ public struct OpenAIDashboardBrowserCookieImporter { // Mismatch still means we found a valid signed-in session. Persist it keyed by its email so if // the user switches Codex accounts later, we can reuse this session immediately without another // Keychain prompt. - await self.persistCookies(candidate: candidate, accountEmail: signedInEmail, logger: log) + await self.persistCookies( + candidate: candidate, + accountEmail: signedInEmail, + workspaceLabel: nil, + logger: log) } private func fetchSignedInEmailFromAPI( @@ -590,9 +627,12 @@ public struct OpenAIDashboardBrowserCookieImporter { private func persist( candidate: Candidate, targetEmail: String, + workspaceLabel: String?, logger: @escaping (String) -> Void) async throws -> ImportResult { - let persistent = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: targetEmail) + let persistent = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: targetEmail, + workspaceLabel: workspaceLabel) await self.clearChatGPTCookies(in: persistent) await self.setCookies(candidate.cookies, into: persistent) @@ -686,11 +726,21 @@ public struct OpenAIDashboardBrowserCookieImporter { // MARK: - WebKit cookie store - private func persistCookies(candidate: Candidate, accountEmail: String, logger: (String) -> Void) async { - let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + private func persistCookies( + candidate: Candidate, + accountEmail: String, + workspaceLabel: String?, + logger: (String) -> Void) async + { + let store = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: accountEmail, + workspaceLabel: workspaceLabel) await self.clearChatGPTCookies(in: store) await self.setCookies(candidate.cookies, into: store) - logger("Persisted cookies for \(accountEmail) (source=\(candidate.label))") + let workspaceSuffix = workspaceLabel.map { " [\($0)]" } ?? "" + logger( + "Persisted cookies for \(accountEmail)\(workspaceSuffix) " + + "(source=\(candidate.label))") } private func clearChatGPTCookies(in store: WKWebsiteDataStore) async { @@ -797,6 +847,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail _: String?, + intoWorkspaceLabel _: String? = nil, allowAnyAccount _: Bool = false, logger _: ((String) -> Void)? = nil) async throws -> ImportResult { @@ -806,6 +857,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importManualCookies( cookieHeader _: String, intoAccountEmail _: String?, + intoWorkspaceLabel _: String? = nil, allowAnyAccount _: Bool = false, logger _: ((String) -> Void)? = nil) async throws -> ImportResult { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index f9dfe030d..f5c056ed1 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -69,11 +69,14 @@ public struct OpenAIDashboardFetcher { public func loadLatestDashboard( accountEmail: String?, + workspaceLabel: String? = nil, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { - let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + let store = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: accountEmail, + workspaceLabel: workspaceLabel) return try await self.loadLatestDashboard( websiteDataStore: store, logger: logger, @@ -274,10 +277,14 @@ public struct OpenAIDashboardFetcher { return false } - public func clearSessionData(accountEmail: String?) async { - let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + public func clearSessionData(accountEmail: String?, workspaceLabel: String? = nil) async { + let store = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: accountEmail, + workspaceLabel: workspaceLabel) OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) - await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: accountEmail) + await OpenAIDashboardWebsiteDataStore.clearStore( + forAccountEmail: accountEmail, + workspaceLabel: workspaceLabel) } public func probeUsagePage( @@ -505,6 +512,7 @@ public struct OpenAIDashboardFetcher { public func loadLatestDashboard( accountEmail _: String?, + workspaceLabel _: String? = nil, logger _: ((String) -> Void)? = nil, debugDumpHTML _: Bool = false, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift index e23ef66a1..424cf2ad9 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift @@ -6,42 +6,41 @@ import WebKit /// Per-account persistent `WKWebsiteDataStore` for the OpenAI dashboard scrape. /// /// Why: `WKWebsiteDataStore.default()` is a single shared cookie jar. If the user switches Codex accounts, -/// we want to keep multiple signed-in dashboard sessions around (one per email) without clearing cookies. +/// we want to keep multiple signed-in dashboard sessions around (one per email/workspace pair) without clearing +/// cookies. /// /// Implementation detail: macOS 14+ supports `WKWebsiteDataStore.dataStore(forIdentifier:)`, which creates -/// persistent isolated stores keyed by an identifier. We derive a stable UUID from the email so the same -/// account always maps to the same cookie store. +/// persistent isolated stores keyed by an identifier. We derive a stable UUID from the normalized email/workspace key +/// so the same account workspace always maps to the same cookie store. /// /// Important: We cache the `WKWebsiteDataStore` instances so the same object is returned for the same -/// account email. This ensures `OpenAIDashboardWebViewCache` can use object identity for cache lookups. +/// account key. This ensures `OpenAIDashboardWebViewCache` can use object identity for cache lookups. @MainActor public enum OpenAIDashboardWebsiteDataStore { - /// Cached data store instances keyed by normalized email. + /// Cached data store instances keyed by normalized account identity. /// Using the same instance ensures stable object identity for WebView cache lookups. private static var cachedStores: [String: WKWebsiteDataStore] = [:] - public static func store(forAccountEmail email: String?) -> WKWebsiteDataStore { - guard let normalized = normalizeEmail(email) else { return .default() } + public static func store(forAccountEmail email: String?, workspaceLabel: String? = nil) -> WKWebsiteDataStore { + guard let normalized = normalizeAccountKey(email: email, workspaceLabel: workspaceLabel) + else { return .default() } - // Return cached instance if available to maintain stable object identity if let cached = cachedStores[normalized] { return cached } - let id = Self.identifier(forNormalizedEmail: normalized) + let id = Self.identifier(forNormalizedKey: normalized) let store = WKWebsiteDataStore(forIdentifier: id) self.cachedStores[normalized] = store return store } - /// Clears the persistent cookie store for a single account email. + /// Clears the persistent cookie store for a single account identity. /// /// Note: this does *not* impact other accounts, and is safe to use when the stored session is "stuck" /// or signed in to a different account than expected. - public static func clearStore(forAccountEmail email: String?) async { - // Clear only ChatGPT/OpenAI domain data for the per-account store. - // Avoid deleting the entire persistent store (WebKit requires all WKWebViews using it to be released). - let store = self.store(forAccountEmail: email) + public static func clearStore(forAccountEmail email: String?, workspaceLabel: String? = nil) async { + let store = self.store(forAccountEmail: email, workspaceLabel: workspaceLabel) await withCheckedContinuation { cont in store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in let filtered = records.filter { record in @@ -54,8 +53,7 @@ public enum OpenAIDashboardWebsiteDataStore { } } - // Remove from cache so a fresh instance is created on next access - if let normalized = normalizeEmail(email) { + if let normalized = normalizeAccountKey(email: email, workspaceLabel: workspaceLabel) { self.cachedStores.removeValue(forKey: normalized) } } @@ -69,13 +67,22 @@ public enum OpenAIDashboardWebsiteDataStore { // MARK: - Private - private static func normalizeEmail(_ email: String?) -> String? { - guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - return raw.lowercased() + private static func normalizeAccountKey(email: String?, workspaceLabel: String?) -> String? { + guard let rawEmail = email?.trimmingCharacters(in: .whitespacesAndNewlines), !rawEmail.isEmpty else { + return nil + } + let normalizedEmail = rawEmail.lowercased() + let normalizedWorkspace = workspaceLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if let normalizedWorkspace, !normalizedWorkspace.isEmpty { + return "\(normalizedEmail)\n\(normalizedWorkspace)" + } + return normalizedEmail } - private static func identifier(forNormalizedEmail email: String) -> UUID { - let digest = SHA256.hash(data: Data(email.utf8)) + private static func identifier(forNormalizedKey key: String) -> UUID { + let digest = SHA256.hash(data: Data(key.utf8)) var bytes = Array(digest.prefix(16)) // Make it a well-formed UUID (v4 + RFC4122 variant) while staying deterministic. diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountLabel.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountLabel.swift new file mode 100644 index 000000000..2c74c8a7a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountLabel.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum CodexAccountLabel { + /// This is not a manual user-labeling system. It parses the stored Codex account label + /// into displayable identity parts so multiple accounts can be distinguished in the menu. + public static let separator = " — " + + public struct Parts: Equatable, Sendable { + public let email: String? + public let workspace: String? + + public init(email: String?, workspace: String?) { + self.email = email + self.workspace = workspace + } + } + + public static func parse(_ label: String) -> Parts { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return Parts(email: nil, workspace: nil) } + + if let range = trimmed.range(of: self.separator) { + let email = String(trimmed[.. String { + let trimmedEmail = email?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedWorkspace = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) + + if let trimmedEmail, !trimmedEmail.isEmpty { + if let trimmedWorkspace, !trimmedWorkspace.isEmpty { + return "\(trimmedEmail)\(self.separator)\(trimmedWorkspace)" + } + return trimmedEmail + } + + return "ChatGPT account \(fallbackIndex)" + } + + public static func uniqueLabel(baseLabel: String, existingAccounts: [ProviderTokenAccount]) -> String { + let existingLabels = Set(existingAccounts.map(\.label)) + guard existingLabels.contains(baseLabel) else { return baseLabel } + + var index = 2 + var candidate = "\(baseLabel) #\(index)" + while existingLabels.contains(candidate) { + index += 1 + candidate = "\(baseLabel) #\(index)" + } + return candidate + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index 2e03d51a3..97d0fc234 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -18,17 +18,32 @@ public struct CodexWebDashboardStrategy: ProviderFetchStrategy { _ = NSApplication.shared } - let accountEmail = context.fetcher.loadAccountInfo().email? - .trimmingCharacters(in: .whitespacesAndNewlines) + let manualCookieHeader = context.settings?.codex?.manualCookieHeader + let selectedAccountEmail = context.settings?.codex?.accountEmail? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let workspaceLabel = context.settings?.codex?.workspaceLabel? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let accountEmail: String? = if let selectedAccountEmail, !selectedAccountEmail.isEmpty { + selectedAccountEmail + } else if manualCookieHeader == nil { + context.fetcher.loadAccountInfo().email? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } else { + nil + } let options = OpenAIWebOptions( timeout: context.webTimeout, debugDumpHTML: context.webDebugDumpHTML, verbose: context.verbose) let result = try await Self.fetchOpenAIWebCodex( - accountEmail: accountEmail, - fetcher: context.fetcher, - options: options, - browserDetection: context.browserDetection) + OpenAIWebFetchRequest( + accountEmail: accountEmail, + workspaceLabel: workspaceLabel, + manualCookieHeader: context.settings?.codex?.manualCookieHeader, + manualMode: context.settings?.codex?.cookieSource == .manual, + fetcher: context.fetcher, + options: options, + browserDetection: context.browserDetection)) return self.makeResult( usage: result.usage, credits: result.credits, @@ -66,6 +81,16 @@ private struct OpenAIWebOptions { let verbose: Bool } +private struct OpenAIWebFetchRequest { + let accountEmail: String? + let workspaceLabel: String? + let manualCookieHeader: String? + let manualMode: Bool + let fetcher: UsageFetcher + let options: OpenAIWebOptions + let browserDetection: BrowserDetection +} + @MainActor private final class WebLogBuffer { private var lines: [String] = [] @@ -96,22 +121,14 @@ private final class WebLogBuffer { extension CodexWebDashboardStrategy { @MainActor fileprivate static func fetchOpenAIWebCodex( - accountEmail: String?, - fetcher: UsageFetcher, - options: OpenAIWebOptions, - browserDetection: BrowserDetection) async throws -> OpenAIWebCodexResult + _ request: OpenAIWebFetchRequest) async throws -> OpenAIWebCodexResult { - let logger = WebLogBuffer(verbose: options.verbose) + let logger = WebLogBuffer(verbose: request.options.verbose) let log: @MainActor (String) -> Void = { line in logger.append(line) } - let dashboard = try await Self.fetchOpenAIWebDashboard( - accountEmail: accountEmail, - fetcher: fetcher, - options: options, - browserDetection: browserDetection, - logger: log) - guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: accountEmail) else { + let dashboard = try await Self.fetchOpenAIWebDashboard(request, logger: log) + guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: request.accountEmail) else { throw OpenAIWebCodexError.missingUsage } let credits = dashboard.toCreditsSnapshot() @@ -120,28 +137,66 @@ extension CodexWebDashboardStrategy { @MainActor fileprivate static func fetchOpenAIWebDashboard( - accountEmail: String?, - fetcher: UsageFetcher, - options: OpenAIWebOptions, - browserDetection: BrowserDetection, + _ request: OpenAIWebFetchRequest, logger: @MainActor @escaping (String) -> Void) async throws -> OpenAIDashboardSnapshot { - let trimmed = accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - let fallback = fetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = request.accountEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let fallback: String? = if request.manualCookieHeader == nil { + request.fetcher.loadAccountInfo().email?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } else { + nil + } let codexEmail = trimmed?.isEmpty == false ? trimmed : (fallback?.isEmpty == false ? fallback : nil) let allowAnyAccount = codexEmail == nil - let importResult = try await OpenAIDashboardBrowserCookieImporter(browserDetection: browserDetection) - .importBestCookies(intoAccountEmail: codexEmail, allowAnyAccount: allowAnyAccount, logger: logger) + if let codexEmail, !codexEmail.isEmpty { + do { + let dash = try await OpenAIDashboardFetcher().loadLatestDashboard( + accountEmail: codexEmail, + workspaceLabel: request.workspaceLabel, + logger: logger, + debugDumpHTML: request.options.debugDumpHTML, + timeout: request.options.timeout) + OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: codexEmail, snapshot: dash)) + return dash + } catch OpenAIDashboardFetcher.FetchError.loginRequired { + logger("stored dashboard session for \(codexEmail) requires login; falling back to cookie import") + } catch { + logger("stored dashboard session for \(codexEmail) failed: \(error.localizedDescription)") + } + } + + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: request.browserDetection) + let importResult: OpenAIDashboardBrowserCookieImporter.ImportResult + if let manualCookieHeader = request.manualCookieHeader, + CookieHeaderNormalizer.normalize(manualCookieHeader) != nil + { + importResult = try await importer.importManualCookies( + cookieHeader: manualCookieHeader, + intoAccountEmail: codexEmail, + intoWorkspaceLabel: request.workspaceLabel, + allowAnyAccount: allowAnyAccount, + logger: logger) + } else if request.manualMode { + throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid + } else { + importResult = try await importer.importBestCookies( + intoAccountEmail: codexEmail, + intoWorkspaceLabel: request.workspaceLabel, + allowAnyAccount: allowAnyAccount, + logger: logger) + } let effectiveEmail = codexEmail ?? importResult.signedInEmail? - .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let dash = try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: effectiveEmail, + workspaceLabel: request.workspaceLabel, logger: logger, - debugDumpHTML: options.debugDumpHTML, - timeout: options.timeout) - let cacheEmail = effectiveEmail ?? dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + debugDumpHTML: request.options.debugDumpHTML, + timeout: request.options.timeout) + let cacheEmail = effectiveEmail ?? dash.signedInEmail? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) if let cacheEmail, !cacheEmail.isEmpty { OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: cacheEmail, snapshot: dash)) } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index c5d9af4f7..fc861ec5e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -41,18 +41,27 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CodexProviderSettings: Sendable { + // Codex multi-account fetches thread account identity through the settings snapshot + // so downstream web fetches and menu rendering can associate each usage result with + // the correct email/workspace context. public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let accountEmail: String? + public let workspaceLabel: String? public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + accountEmail: String?, + workspaceLabel: String?) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.accountEmail = accountEmail + self.workspaceLabel = workspaceLabel } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d4..449fda790 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,6 +2,13 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ + .codex: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store ChatGPT/OpenAI Cookie headers for multiple Codex accounts.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), .claude: TokenAccountSupport( title: "Session tokens", subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 924da2a05..671558771 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -124,6 +124,128 @@ struct ProviderSettingsDescriptorTests { #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } + @Test + func `codex token accounts visible when manual cookie mode enabled`() throws { + let suite = "ProviderSettingsDescriptorTests-codex-token-visibility" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexCookieSource = .manual + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let support = try #require(TokenAccountSupportCatalog.support(for: .codex)) + #expect(CodexProviderImplementation().tokenAccountsVisibility(context: context, support: support)) + } + + @Test + func `codex apply token accounts forces manual cookie mode`() throws { + let suite = "ProviderSettingsDescriptorTests-codex-token-force-manual" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexCookieSource = .auto + + CodexProviderImplementation().applyTokenAccountCookieSource(settings: settings) + + #expect(settings.codexCookieSource == .manual) + } + + @Test + func `codex manual cookies preserve auto source mode`() throws { + let suite = "ProviderSettingsDescriptorTests-codex-manual-web" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexUsageDataSource = .auto + settings.codexCookieSource = .manual + + let mode = CodexProviderImplementation().sourceMode( + context: ProviderSourceModeContext(provider: .codex, settings: settings)) + + #expect(mode == .auto) + } + + @Test + func `codex cookie picker remains visible when off`() throws { + let suite = "ProviderSettingsDescriptorTests-codex-cookie-picker-visible-when-off" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + settings.codexCookieSource = .off + + let context = ProviderSettingsContext( + provider: .codex, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let pickers = CodexProviderImplementation().settingsPickers(context: context) + let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) + #expect(cookiePicker.isVisible?() == true) + } + @Test func `claude exposes usage and cookie pickers`() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 45956f0b6..bdda6591e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -623,13 +623,11 @@ struct StatusMenuTests { #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) - #expect( - creditsItem?.submenu?.items - .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) + #expect(creditsItem == nil) } @Test - func `shows credits before cost in codex menu card sections`() throws { + func `omits separate credits card in codex menu card sections`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -690,9 +688,8 @@ struct StatusMenuTests { let ids = menu.items.compactMap { $0.representedObject as? String } let creditsIndex = ids.firstIndex(of: "menuCardCredits") let costIndex = ids.firstIndex(of: "menuCardCost") - #expect(creditsIndex != nil) + #expect(creditsIndex == nil) #expect(costIndex != nil) - #expect(try #require(creditsIndex) < costIndex!) } @Test diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index b3eb4e5a0..9bbc3aa00 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -74,6 +74,29 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(ollamaSettings.manualCookieHeader == "session=account-token") } + @Test + func `codex supports token accounts`() { + let support = TokenAccountSupportCatalog.support(for: .codex) + #expect(support != nil) + #expect(support?.requiresManualCookieSource == true) + #expect(support?.cookieName == nil) + } + + @Test + func `codex token account selection forces manual cookie source in app settings snapshot`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-app") + settings.codexCookieSource = .auto + settings.addTokenAccount(provider: .codex, label: "Account 1", token: "Cookie: a=b") + + let account = try #require(settings.selectedTokenAccount(for: .codex)) + let snapshot = settings.codexSettingsSnapshot(tokenOverride: TokenAccountOverride( + provider: .codex, + account: account)) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "Cookie: a=b") + } + @Test func `claude OAuth token account overrides environment in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app") diff --git a/docs/CODEX_MULTI_ACCOUNT.md b/docs/CODEX_MULTI_ACCOUNT.md new file mode 100644 index 000000000..a7bfb089b --- /dev/null +++ b/docs/CODEX_MULTI_ACCOUNT.md @@ -0,0 +1,79 @@ +# Codex multi-account feature + +## Goal +Add support for **multiple Codex / ChatGPT accounts** for the Codex provider, so multiple accounts can be shown together in the menu with their own separate usage state. + +## User problem / motivation +My use case was simple: I often have more than one Codex account available, and I wanted to see them together in the menu bar, with each account's usage/limits visible at the same time. + +The goal here is not to aggregate all accounts into one total. The goal is to keep accounts separate while making them visible together. + +## What this implementation does +For Codex, this turns the menu from a single-account usage view into a multi-account view. + +In practice, it adds three things: +- multiple Codex accounts shown together in the menu, each with its own usage card +- account identity shown using the Codex account email and whether the account is personal vs team/workspace-backed +- sorting for the account cards + +Sorting matters because once multiple accounts are visible together, it becomes useful to answer practical questions like: +- which account resets soonest? +- which account still has the most usage left? + +## Technical direction +The implementation reuses CodexBar's existing token-account architecture rather than inventing a separate Codex-only account system. + +That direction was chosen because: +- token account storage already exists +- token-account selection / show-all behavior already exists +- Codex already had some token-override-aware logic +- reusing existing abstractions keeps the implementation smaller and easier to reason about + +## Implementation strategy +The basic route is: +1. Add Codex to the token-account support catalog. +2. Reuse token-account overrides to fetch Codex usage per stored account. +3. Thread enough account identity through the fetch pipeline to distinguish results in the UI. +4. Store per-account snapshots separately instead of collapsing everything into one Codex snapshot. +5. Render those account-scoped snapshots together in the Codex menu. +6. Add sorting and refresh behavior needed to keep the multi-account view usable. + +This means the feature is built by making Codex participate in the existing token-account flow, not by creating a completely separate multi-account subsystem. + +## Current scope +This implementation is intentionally limited. + +Included: +- multi-account Codex support using the existing token-account path +- stacked per-account Codex cards in the menu +- account identity/context in the menu +- sorting for multi-account Codex display + +Not included: +- aggregated totals across accounts +- broader refactors of global Codex/OpenAI dashboard state +- generalized multi-account support across every provider + +## Related research +I also looked at `lukilabs/craft-agents-oss`, because it already supports multiple ChatGPT/Codex accounts. + +The useful conclusion from that comparison was that it solves a **different layer** of the problem. + +Its model is based on: +- multiple named LLM connections +- separate auth per connection +- OAuth-based ChatGPT/Codex auth + +CodexBar's model here is different: +- provider usage snapshots +- quota / credits tracking +- menu-card rendering +- per-provider account display + +So that project was useful as inspiration, especially for future OAuth-backed ideas, but not as a drop-in implementation source for this codebase. + +## Possible improvements +Potential future improvements include: +- allowing manual labels for accounts instead of relying only on detected account identity/context +- exploring richer OAuth-backed multi-account Codex support +- evaluating whether the same general pattern should be extended to other providers that fit the token-account/session model diff --git a/docs/FORK_WORKING_RULES.md b/docs/FORK_WORKING_RULES.md new file mode 100644 index 000000000..8b3e33c02 --- /dev/null +++ b/docs/FORK_WORKING_RULES.md @@ -0,0 +1,33 @@ +# Fork working rules + +This repository is a private downstream copy of upstream `steipete/CodexBar`. + +## Main rule +Design changes so future upstream syncs stay easy. + +When choosing between two implementations, prefer the one that is easier to merge with future upstream changes. + +## Prefer +- additive changes +- localized changes +- minimal diffs +- isolated helpers +- feature flags or settings when possible +- preserving upstream file structure and naming unless there is a strong reason not to + +## Avoid +- broad refactors without a clear payoff +- unnecessary renames or file moves +- formatting churn unrelated to the feature +- invasive edits in likely upstream hotspot files unless necessary +- changing shared abstractions more than needed for the feature + +## Working lens +Before making a change, ask: +1. Is this the smallest change that solves the problem? +2. How likely is this area to change upstream? +3. Can this be implemented in a more isolated way? +4. Will this make future merges or rebases harder? + +## Goal +Keep this fork easy to evolve while still being able to regularly import improvements from upstream CodexBar. From c160acc027f1a0bf0b374d697e1d954636bf5751 Mon Sep 17 00:00:00 2001 From: Monter <261478260+monterrr@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:52:25 +0100 Subject: [PATCH 2/3] Remove fork-internal docs from PR Co-Authored-By: Craft Agent --- FORK_STATUS.md | 345 ------------------------------------- docs/FORK_WORKING_RULES.md | 33 ---- 2 files changed, 378 deletions(-) delete mode 100644 FORK_STATUS.md delete mode 100644 docs/FORK_WORKING_RULES.md diff --git a/FORK_STATUS.md b/FORK_STATUS.md deleted file mode 100644 index 7f991dddf..000000000 --- a/FORK_STATUS.md +++ /dev/null @@ -1,345 +0,0 @@ -# CodexBar Fork - Current Status - -**Last Updated:** January 4, 2026 -**Fork Maintainer:** Brandon Charleson -**Branch:** `feature/augment-integration` - ---- - -## ✅ Completed Work - -### Phase 1: Fork Identity & Credits ✓ - -**Commits:** -1. `da3d13e` - "feat: establish fork identity with dual attribution" -2. `745293e` - "docs: add fork roadmap and quick start guide" -3. `8a87473` - "docs: add fork status tracking document" -4. `df75ae2` - "feat: comprehensive multi-upstream fork management system" - -**Changes:** -- ✅ Updated About section with dual attribution (original + fork) -- ✅ Updated PreferencesAboutPane with organized sections -- ✅ Changed app icon click to open fork repository -- ✅ Updated README with fork notice and enhancements section -- ✅ Created comprehensive `docs/augment.md` documentation -- ✅ Created `docs/FORK_ROADMAP.md` with 5-phase plan -- ✅ Created `docs/FORK_QUICK_START.md` developer guide -- ✅ Created `FORK_STATUS.md` tracking document -- ✅ **Implemented complete multi-upstream management system** - -**Build Status:** ✅ App builds and runs successfully - -### Multi-Upstream Management System ✓ - -**Automation Scripts:** -- ✅ `Scripts/check_upstreams.sh` - Monitor both upstreams -- ✅ `Scripts/review_upstream.sh` - Create review branches -- ✅ `Scripts/prepare_upstream_pr.sh` - Prepare upstream PRs -- ✅ `Scripts/analyze_quotio.sh` - Analyze quotio patterns - -**GitHub Actions:** -- ✅ `.github/workflows/upstream-monitor.yml` - Automated monitoring - -**Documentation:** -- ✅ `docs/UPSTREAM_STRATEGY.md` - Complete management guide -- ✅ `docs/QUOTIO_ANALYSIS.md` - Pattern analysis framework -- ✅ `docs/FORK_SETUP.md` - One-time setup guide - ---- - -## 🎯 Current State - -### What Works -- ✅ Fork identity clearly established -- ✅ Dual attribution in place (original + fork) -- ✅ Comprehensive documentation -- ✅ Clear development roadmap -- ✅ App builds without errors -- ✅ All existing functionality preserved -- ✅ **Multi-upstream management system operational** -- ✅ **Automated upstream monitoring configured** -- ✅ **Quotio analysis framework ready** - -### Critical Discovery -- ⚠️ **Upstream (steipete) has REMOVED Augment provider** - - 627 lines deleted from `AugmentStatusProbe.swift` - - 88 lines deleted from `AugmentStatusProbeTests.swift` - - **This validates our fork strategy!** - - We preserve Augment support for our users - - We can selectively sync other improvements - -### Known Issues -- ⚠️ Augment cookie disconnection (Phase 2 will address) -- ⚠️ Debug print statements in AugmentStatusProbe.swift (needs proper logging) - -### Known Local Test Harness Limitation (2026-03-22) -- ⚠️ Local `swift test` runs for AppKit status-bar suites can crash under `swiftpm-testing-helper` when tests instantiate a standalone `NSStatusBar()`. -- Reproduced with `StatusItemAnimationTests` and `StatusMenuTests` on local runs; crash reports point into AppKit drawing (`NSStatusBarButtonCell drawWithFrame:inView:`) with `EXC_BAD_ACCESS` / `SIGSEGV` or `SIGBUS`. -- The same suites pass with `CI=true`, which makes the test helper use `NSStatusBar.system` instead of `NSStatusBar()`. -- Current assessment: this looks like a local AppKit test-harness issue, not a product regression in the running app. -- Temporary workaround for local verification: run affected suites with `CI=true` until the test harness is adjusted. - -### Uncommitted Changes -- `Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift` has debug print statements - - These should be replaced with proper `CodexBarLog` logging in Phase 2 - - Currently unstaged to keep commits clean - ---- - -## 📋 Next Steps - -### URGENT: Upstream Sync Decision -**Before proceeding with Phase 2, decide on upstream sync strategy:** - -1. **Review upstream changes:** - ```bash - ./Scripts/check_upstreams.sh upstream - ./Scripts/review_upstream.sh upstream - ``` - -2. **Decide what to sync:** - - ✅ Vertex AI improvements (5 commits) - - ✅ SwiftFormat/SwiftLint fixes - - ❌ Augment provider removal (SKIP!) - -3. **Cherry-pick valuable commits:** - ```bash - git checkout -b upstream-sync/vertex-improvements - git cherry-pick 001019c # style fixes - git cherry-pick e4f1e4c # vertex token cost - git cherry-pick 202efde # vertex fix - git cherry-pick 0c2f888 # vertex docs - git cherry-pick 3c4ca30 # vertex tracking - # Skip Augment removal commits! - ``` - -### Immediate (Phase 2) -1. **Replace debug prints with proper logging** - - Use `CodexBarLog.logger("augment")` pattern - - Add structured metadata - - Follow Claude/Cursor provider patterns - -2. **Enhanced cookie diagnostics** - - Log cookie expiration times - - Track refresh attempts - - Add domain filtering diagnostics - -3. **Session keepalive monitoring** - - Add keepalive status to debug pane - - Log refresh attempts - - Add manual "Force Refresh" button - -### Short Term (Phases 3-4) -- **Analyze Quotio features** using `./Scripts/analyze_quotio.sh` -- **Regular upstream monitoring** (automated via GitHub Actions) -- **Weekly sync routine** (Monday: upstream, Thursday: quotio) - -### Medium Term (Phase 5) -- Implement multi-account management (inspired by quotio) -- Start with Augment provider -- Extend to other providers - ---- - -## 📁 Key Files Modified - -### Source Code -- `Sources/CodexBar/About.swift` - Dual attribution -- `Sources/CodexBar/PreferencesAboutPane.swift` - Organized sections -- `Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift` - Debug prints (unstaged) - -### Documentation -- `README.md` - Fork notice and enhancements -- `docs/augment.md` - Augment provider guide (NEW) -- `docs/FORK_ROADMAP.md` - Development roadmap (NEW) -- `docs/FORK_QUICK_START.md` - Quick reference (NEW) - ---- - -## 🔄 Git Status - -```bash -# Current branch -feature/augment-integration - -# Commits ahead of main -4 commits: -- da3d13e: Fork identity with dual attribution -- 745293e: Roadmap and quick start guide -- 8a87473: Fork status tracking -- df75ae2: Multi-upstream management system - -# Uncommitted changes -M Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift (debug prints) - -# Git remotes configured -origin git@github.com:topoffunnel/CodexBar.git -upstream https://github.com/steipete/CodexBar.git (needs to be added) -quotio https://github.com/nguyenphutrong/quotio.git (needs to be added) -``` - ---- - -## 🚀 How to Continue - -### RECOMMENDED: Setup Multi-Upstream System First - -```bash -# 1. Configure git remotes -git remote add upstream https://github.com/steipete/CodexBar.git -git remote add quotio https://github.com/nguyenphutrong/quotio.git -git fetch --all - -# 2. Test automation scripts -./Scripts/check_upstreams.sh - -# 3. Review upstream changes (IMPORTANT!) -./Scripts/review_upstream.sh upstream - -# 4. Decide what to sync -# See "URGENT: Upstream Sync Decision" section above - -# 5. Analyze quotio -./Scripts/analyze_quotio.sh -``` - -### Option 1: Sync Upstream First, Then Phase 2 -```bash -# Discard debug prints (will redo in Phase 2) -git checkout Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift - -# Sync valuable upstream changes -git checkout -b upstream-sync/vertex-improvements -# Cherry-pick commits (see URGENT section) - -# Merge to main -git checkout main -git merge feature/augment-integration -git merge upstream-sync/vertex-improvements - -# Then start Phase 2 -git checkout -b feature/augment-diagnostics -``` - -### Option 2: Phase 2 First, Sync Later -```bash -# Keep debug prints and enhance them -git add Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift - -# Continue on current branch -# Replace print() with CodexBarLog.logger("augment") -# Complete Phase 2 -# Then sync upstream -``` - -### Option 3: Merge Current Work, Setup System -```bash -# Discard debug prints -git checkout Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift - -# Merge to main -git checkout main -git merge feature/augment-integration - -# Setup remotes -git remote add upstream https://github.com/steipete/CodexBar.git -git remote add quotio https://github.com/nguyenphutrong/quotio.git - -# Start using the system -./Scripts/check_upstreams.sh -``` - ---- - -## 📊 Progress Tracking - -### Phase 1: Fork Identity ✅ COMPLETE -- [x] Dual attribution in About -- [x] Fork notice in README -- [x] Augment documentation -- [x] Development roadmap -- [x] Quick start guide - -### Phase 2: Enhanced Diagnostics 🔄 READY TO START -- [ ] Replace print() with CodexBarLog -- [ ] Enhanced cookie diagnostics -- [ ] Session keepalive monitoring -- [ ] Debug pane improvements - -### Phase 3: Quotio Analysis 📋 PLANNED -- [ ] Feature comparison matrix -- [ ] Implementation recommendations -- [ ] Priority ranking - -### Phase 4: Upstream Sync 📋 PLANNED -- [ ] Sync script -- [ ] Conflict resolution guide -- [ ] Automated checks - -### Phase 5: Multi-Account 📋 PLANNED -- [ ] Account management UI -- [ ] Account storage -- [ ] Account switching -- [ ] UI enhancements - ---- - -## 🎯 Success Criteria - -### Phase 1 (Current) ✅ -- [x] Fork identity clearly established -- [x] Original author properly credited -- [x] Comprehensive documentation -- [x] App builds and runs -- [x] No regressions - -### Phase 2 (Next) -- [ ] Zero cookie disconnection issues -- [ ] Proper structured logging -- [ ] Enhanced debug diagnostics -- [ ] Manual refresh capability -- [ ] All tests passing - ---- - -## 📞 Questions & Decisions Needed - -### Before Starting Phase 2 -1. **Logging approach:** Keep debug prints and enhance, or start fresh? -2. **Branch strategy:** Continue on `feature/augment-integration` or create new branch? -3. **Merge timing:** Merge Phase 1 to main first, or continue with all phases? - -### For Phase 3 -1. **Quotio access:** Do you have access to Quotio source code? -2. **Feature priority:** Which Quotio features are most important? -3. **Timeline:** How much time to allocate for analysis? - -### For Phase 5 -1. **Account limit:** How many accounts per provider? -2. **UI design:** Menu bar dropdown or separate window? -3. **Storage:** Keychain per account or shared? - ---- - -## 🔗 Quick Links - -- **Roadmap:** `docs/FORK_ROADMAP.md` -- **Quick Start:** `docs/FORK_QUICK_START.md` -- **Augment Docs:** `docs/augment.md` -- **Original Repo:** https://github.com/steipete/CodexBar -- **Fork Repo:** https://github.com/topoffunnel/CodexBar - ---- - -## 💡 Recommendations - -1. **Merge Phase 1 to main** - Establish fork identity as baseline -2. **Create Phase 2 branch** - `feature/augment-diagnostics` -3. **Start with logging** - Replace prints with proper CodexBarLog -4. **Test thoroughly** - Ensure no regressions -5. **Document as you go** - Update docs with findings - ---- - -**Ready to proceed with Phase 2?** See `docs/FORK_ROADMAP.md` for detailed tasks. - diff --git a/docs/FORK_WORKING_RULES.md b/docs/FORK_WORKING_RULES.md deleted file mode 100644 index 8b3e33c02..000000000 --- a/docs/FORK_WORKING_RULES.md +++ /dev/null @@ -1,33 +0,0 @@ -# Fork working rules - -This repository is a private downstream copy of upstream `steipete/CodexBar`. - -## Main rule -Design changes so future upstream syncs stay easy. - -When choosing between two implementations, prefer the one that is easier to merge with future upstream changes. - -## Prefer -- additive changes -- localized changes -- minimal diffs -- isolated helpers -- feature flags or settings when possible -- preserving upstream file structure and naming unless there is a strong reason not to - -## Avoid -- broad refactors without a clear payoff -- unnecessary renames or file moves -- formatting churn unrelated to the feature -- invasive edits in likely upstream hotspot files unless necessary -- changing shared abstractions more than needed for the feature - -## Working lens -Before making a change, ask: -1. Is this the smallest change that solves the problem? -2. How likely is this area to change upstream? -3. Can this be implemented in a more isolated way? -4. Will this make future merges or rebases harder? - -## Goal -Keep this fork easy to evolve while still being able to regularly import improvements from upstream CodexBar. From 72c606114cbed453b33ed8b18a88958e0409a4f9 Mon Sep 17 00:00:00 2001 From: Monter <261478260+monterrr@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:24:45 +0100 Subject: [PATCH 3/3] Fix Codex workspace identity edge cases Co-Authored-By: Craft Agent --- .../Codex/CodexProviderRuntime.swift | 4 +- .../CodexBarCore/OpenAIDashboardModels.swift | 3 +- ...OpenAIDashboardBrowserCookieImporter.swift | 118 +++++++++++++++++- .../Codex/CodexWebDashboardStrategy.swift | 6 +- ...IDashboardBrowserCookieImporterTests.swift | 7 ++ .../OpenAIDashboardParserTests.swift | 25 ++++ ...kenAccountEnvironmentPrecedenceTests.swift | 17 +++ 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift index 8285e4122..527dc6f43 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift @@ -9,6 +9,7 @@ final class CodexProviderRuntime: ProviderRuntime { let cookieSource: ProviderCookieSource let hasManualCookieHeader: Bool let hasTokenAccounts: Bool + let selectedTokenAccountID: UUID? var hasManualCredentials: Bool { self.hasManualCookieHeader || self.hasTokenAccounts @@ -20,7 +21,8 @@ final class CodexProviderRuntime: ProviderRuntime { return Self( cookieSource: settings.codexCookieSource, hasManualCookieHeader: !manualHeader.isEmpty, - hasTokenAccounts: !settings.tokenAccounts(for: .codex).isEmpty) + hasTokenAccounts: !settings.tokenAccounts(for: .codex).isEmpty, + selectedTokenAccountID: settings.selectedTokenAccount(for: .codex)?.id) } } diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index e4fe3a06f..5e56d1b94 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -118,6 +118,7 @@ extension OpenAIDashboardSnapshot { public func toUsageSnapshot( provider: UsageProvider = .codex, accountEmail: String? = nil, + accountOrganization: String? = nil, accountPlan: String? = nil) -> UsageSnapshot? { guard let primaryLimit else { return nil } @@ -126,7 +127,7 @@ extension OpenAIDashboardSnapshot { let identity = ProviderIdentitySnapshot( providerID: provider, accountEmail: resolvedEmail, - accountOrganization: nil, + accountOrganization: accountOrganization, loginMethod: resolvedPlan) return UsageSnapshot( primary: primaryLimit, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 990810033..2f7f64239 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -92,6 +92,13 @@ public struct OpenAIDashboardBrowserCookieImporter { let log: (String) -> Void } + private struct TargetMatchContext { + let targetEmail: String + let targetWorkspaceLabel: String? + let candidateLabel: String + let log: (String) -> Void + } + private static let cookieDomains = ["chatgpt.com", "openai.com"] private static let cookieClient = BrowserCookieClient() private static let cookieImportOrder: BrowserCookieImportOrder = @@ -213,6 +220,7 @@ public struct OpenAIDashboardBrowserCookieImporter { switch await self.evaluateCandidate( candidate, targetEmail: normalizedTarget, + targetWorkspaceLabel: targetWorkspaceLabel, allowAnyAccount: allowAnyAccount, log: log) { @@ -421,6 +429,7 @@ public struct OpenAIDashboardBrowserCookieImporter { switch await self.evaluateCandidate( candidate, targetEmail: context.targetEmail, + targetWorkspaceLabel: context.targetWorkspaceLabel, allowAnyAccount: context.allowAnyAccount, log: context.log) { @@ -475,11 +484,17 @@ public struct OpenAIDashboardBrowserCookieImporter { private func evaluateCandidate( _ candidate: Candidate, targetEmail: String?, + targetWorkspaceLabel: String?, allowAnyAccount: Bool, log: @escaping (String) -> Void) async -> CandidateEvaluation { log("Trying candidate \(candidate.label) (\(candidate.cookies.count) cookies)") + let resolvedWorkspaceLabel = self.resolveWorkspaceLabel(from: candidate.cookies) + if let resolvedWorkspaceLabel, !resolvedWorkspaceLabel.isEmpty { + log("Candidate \(candidate.label) workspace: \(resolvedWorkspaceLabel)") + } + let apiEmail = await self.fetchSignedInEmailFromAPI(cookies: candidate.cookies, logger: log) if let apiEmail { log("Candidate \(candidate.label) API email: \(apiEmail)") @@ -488,7 +503,15 @@ public struct OpenAIDashboardBrowserCookieImporter { // Prefer the API email when available (fast; avoids WebKit hydration/timeout risks). if let apiEmail, !apiEmail.isEmpty { if let targetEmail { - if apiEmail.lowercased() == targetEmail.lowercased() { + if self.matchesTarget( + signedInEmail: apiEmail, + candidateWorkspaceLabel: resolvedWorkspaceLabel, + context: TargetMatchContext( + targetEmail: targetEmail, + targetWorkspaceLabel: targetWorkspaceLabel, + candidateLabel: candidate.label, + log: log)) + { return .match(candidate: candidate, signedInEmail: apiEmail) } return .mismatch(candidate: candidate, signedInEmail: apiEmail) @@ -515,7 +538,15 @@ public struct OpenAIDashboardBrowserCookieImporter { let resolvedEmail = signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if let resolvedEmail, !resolvedEmail.isEmpty { if let targetEmail { - if resolvedEmail.lowercased() == targetEmail.lowercased() { + if self.matchesTarget( + signedInEmail: resolvedEmail, + candidateWorkspaceLabel: resolvedWorkspaceLabel, + context: TargetMatchContext( + targetEmail: targetEmail, + targetWorkspaceLabel: targetWorkspaceLabel, + candidateLabel: candidate.label, + log: log)) + { return .match(candidate: candidate, signedInEmail: resolvedEmail) } return .mismatch(candidate: candidate, signedInEmail: resolvedEmail) @@ -544,6 +575,81 @@ public struct OpenAIDashboardBrowserCookieImporter { return false } + private func matchesTarget( + signedInEmail: String, + candidateWorkspaceLabel: String?, + context: TargetMatchContext) -> Bool + { + guard signedInEmail.lowercased() == context.targetEmail.lowercased() else { return false } + + let normalizedTargetWorkspace = self.normalizeWorkspaceLabel(context.targetWorkspaceLabel) + guard let normalizedTargetWorkspace else { return true } + + let normalizedCandidateWorkspace = self.normalizeWorkspaceLabel(candidateWorkspaceLabel) + guard let normalizedCandidateWorkspace else { + context.log( + "Candidate \(context.candidateLabel) matched email but workspace is unknown; " + + "expected \(normalizedTargetWorkspace)") + return false + } + + if normalizedCandidateWorkspace == normalizedTargetWorkspace { + return true + } + + context.log( + "Candidate \(context.candidateLabel) matched email but workspace mismatched " + + "(candidate=\(normalizedCandidateWorkspace), target=\(normalizedTargetWorkspace))") + return false + } + + private func normalizeWorkspaceLabel(_ label: String?) -> String? { + let trimmed = label?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed.lowercased() + } + + public func _normalizeWorkspaceLabelForTesting(_ label: String?) -> String? { + self.normalizeWorkspaceLabel(label) + } + + private func resolveWorkspaceLabel(from cookies: [HTTPCookie]) -> String? { + guard let accountID = cookies.first(where: { $0.name == "_account" })?.value, + !accountID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let sessionCookie = cookies.first(where: { $0.name == "oai-client-auth-session" })?.value, + let payload = self.decodeBase64URLJSONPayload(fromCookieValue: sessionCookie), + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let workspaces = json["workspaces"] as? [[String: Any]] + else { + return nil + } + + guard let workspace = workspaces.first(where: { + ($0["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) == accountID + }) else { + return nil + } + + let name = (workspace["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let name, !name.isEmpty { return name } + + let kind = (workspace["kind"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if kind == "personal" { return "Personal" } + return nil + } + + private func decodeBase64URLJSONPayload(fromCookieValue value: String) -> Data? { + let prefix = value.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? value + guard !prefix.isEmpty else { return nil } + var base64 = prefix.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder != 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: base64) + } + private func handleMismatch( candidate: Candidate, signedInEmail: String, @@ -552,13 +658,13 @@ public struct OpenAIDashboardBrowserCookieImporter { { log("Candidate \(candidate.label) mismatch (\(signedInEmail)); continuing browser search") diagnostics.mismatches.append(FoundAccount(sourceLabel: candidate.label, email: signedInEmail)) - // Mismatch still means we found a valid signed-in session. Persist it keyed by its email so if - // the user switches Codex accounts later, we can reuse this session immediately without another - // Keychain prompt. + // Mismatch still means we found a valid signed-in session. Persist it keyed by the + // candidate's resolved email/workspace so later account switches can reuse the right + // browser state without collapsing same-email workspaces together. await self.persistCookies( candidate: candidate, accountEmail: signedInEmail, - workspaceLabel: nil, + workspaceLabel: self.resolveWorkspaceLabel(from: candidate.cookies), logger: log) } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index 97d0fc234..6d300caea 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -128,7 +128,11 @@ extension CodexWebDashboardStrategy { logger.append(line) } let dashboard = try await Self.fetchOpenAIWebDashboard(request, logger: log) - guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: request.accountEmail) else { + guard let usage = dashboard.toUsageSnapshot( + provider: .codex, + accountEmail: request.accountEmail, + accountOrganization: request.workspaceLabel) + else { throw OpenAIWebCodexError.missingUsage } let credits = dashboard.toCreditsSnapshot() diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index a23c8deb7..a30bda465 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -13,4 +13,11 @@ struct OpenAIDashboardBrowserCookieImporterTests { #expect(msg.contains("Safari=a@example.com")) #expect(msg.contains("Chrome=b@example.com")) } + + @Test @MainActor + func `normalize workspace label trims and lowercases`() { + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(importer._normalizeWorkspaceLabelForTesting(" Team Workspace ") == "team workspace") + #expect(importer._normalizeWorkspaceLabelForTesting(nil) == nil) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index 7379dfe9f..2a5d69ac9 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -156,4 +156,29 @@ struct OpenAIDashboardParserTests { let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) #expect(snapshot.usageBreakdown.isEmpty) } + + @Test + func `to usage snapshot preserves account organization override`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "Plus", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot( + provider: .codex, + accountEmail: "user@example.com", + accountOrganization: "Team Workspace") + + #expect(usage?.accountEmail(for: .codex) == "user@example.com") + #expect(usage?.accountOrganization(for: .codex) == "Team Workspace") + #expect(usage?.loginMethod(for: .codex) == "Plus") + } } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 9bbc3aa00..710dc01f1 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -97,6 +97,23 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(snapshot.manualCookieHeader == "Cookie: a=b") } + @Test + func `codex token account selection preserves workspace label in app settings snapshot`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-workspace") + settings.addTokenAccount( + provider: .codex, + label: "user@example.com — Team Workspace", + token: "Cookie: a=b") + + let account = try #require(settings.selectedTokenAccount(for: .codex)) + let snapshot = settings.codexSettingsSnapshot(tokenOverride: TokenAccountOverride( + provider: .codex, + account: account)) + + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.workspaceLabel == "Team Workspace") + } + @Test func `claude OAuth token account overrides environment in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app")