#if os(macOS) import CoreGraphics import Foundation import WebKit @MainActor public struct OpenAIDashboardFetcher { public enum FetchError: LocalizedError { case loginRequired case noDashboardData(body: String) public var errorDescription: String? { switch self { case .loginRequired: "OpenAI web access requires login." case let .noDashboardData(body): "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" } } } private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! private nonisolated static let dashboardAcceptLanguage = "en-US,en;q=0.9" private nonisolated static let dashboardUsageAPIURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! public init() {} public nonisolated static func offscreenHostWindowFrame(for visibleFrame: CGRect) -> CGRect { let width: CGFloat = min(1200, visibleFrame.width) let height: CGFloat = min(1600, visibleFrame.height) // Keep the WebView "visible" for WebKit hydration, but never show it to the user. // Place the window almost entirely off-screen; leave only a 1×1 px intersection. let sliver: CGFloat = 1 return CGRect( x: visibleFrame.maxX - sliver, y: visibleFrame.maxY - sliver, width: width, height: height) } public nonisolated static func offscreenHostAlphaValue() -> CGFloat { // Must be > 0 or WebKit can throttle hydration/timers on the Codex usage SPA. 0.001 } private struct DashboardSnapshotComponents { let signedInEmail: String? let scrape: ScrapeResult let codeReview: Double? let codeReviewLimit: RateWindow? let events: [CreditEvent] let breakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let rateLimits: (primary: RateWindow?, secondary: RateWindow?) let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let codexCreditLimit: CodexCreditLimitSnapshot? let accountPlan: String? } private struct DashboardScrapeData { let signedInEmail: String? let codeReview: Double? let codeReviewLimit: RateWindow? let events: [CreditEvent] let breakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let rateLimits: (primary: RateWindow?, secondary: RateWindow?) let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let codexCreditLimit: CodexCreditLimitSnapshot? let accountPlan: String? let hasDashboardPageSignal: Bool let hasReturnableData: Bool } private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) -> OpenAIDashboardSnapshot { OpenAIDashboardSnapshot( signedInEmail: components.signedInEmail, codeReviewRemainingPercent: components.codeReview, codeReviewLimit: components.codeReviewLimit, creditEvents: components.events, dailyBreakdown: components.breakdown, usageBreakdown: components.usageBreakdown, creditsPurchaseURL: components.scrape.creditsPurchaseURL, primaryLimit: components.rateLimits.primary, secondaryLimit: components.rateLimits.secondary, extraRateWindows: components.extraRateWindows.isEmpty ? nil : components.extraRateWindows, creditsRemaining: components.creditsRemaining, codexCreditLimit: components.codexCreditLimit, accountPlan: components.accountPlan, updatedAt: Date()) } private static func parseDashboardScrape( _ scrape: ScrapeResult, apiData: DashboardAPIData?, verifiedSignedInEmail: String?) -> DashboardScrapeData { let bodyText = scrape.bodyText ?? "" let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows) let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) let usageBreakdown = scrape.usageBreakdown let parsedRateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) let rateLimits = ( primary: apiData?.primaryLimit ?? parsedRateLimits.primary, secondary: apiData?.secondaryLimit ?? parsedRateLimits.secondary) let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText) let parsedCreditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) let creditsRemaining = apiData?.creditsRemaining ?? parsedCreditsRemaining let codexCreditLimit = apiData?.codexCreditLimit let accountPlan = scrape.accountPlan ?? apiData?.accountPlan let hasParsedUsageLimits = parsedRateLimits.primary != nil || parsedRateLimits.secondary != nil let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil let hasDashboardPageData = self.hasReturnableDashboardData(.init( codeReview: codeReview, events: events, usageBreakdown: usageBreakdown, hasUsageLimits: hasParsedUsageLimits, creditsRemaining: parsedCreditsRemaining, codexCreditLimit: nil)) let hasReturnableData = self.hasReturnableDashboardData(.init( codeReview: codeReview, events: events, usageBreakdown: usageBreakdown, hasUsageLimits: hasUsageLimits, creditsRemaining: creditsRemaining, codexCreditLimit: codexCreditLimit)) // Codex `additional_rate_limits` (e.g. Codex Spark) only ship over the JSON usage API, so the // dashboard HTML scrape never contributes here; we just forward what the apiData decoded. let extraRateWindows = apiData?.extraRateWindows ?? [] return DashboardScrapeData( signedInEmail: self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail), codeReview: codeReview, codeReviewLimit: codeReviewLimit, events: events, breakdown: breakdown, usageBreakdown: usageBreakdown, rateLimits: rateLimits, extraRateWindows: extraRateWindows, creditsRemaining: creditsRemaining, codexCreditLimit: codexCreditLimit, accountPlan: accountPlan, hasDashboardPageSignal: self.hasAnyDashboardSignal( hasReturnableData: hasDashboardPageData, creditsHeaderPresent: scrape.creditsHeaderPresent), hasReturnableData: hasReturnableData) } struct DashboardAPIData { let primaryLimit: RateWindow? let secondaryLimit: RateWindow? let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let codexCreditLimit: CodexCreditLimitSnapshot? let accountPlan: String? var hasUsageData: Bool { self.primaryLimit != nil || self.secondaryLimit != nil || self.creditsRemaining != nil || self.codexCreditLimit != nil } } public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool public let workspacePicker: Bool public let cloudflareInterstitial: Bool public let signedInEmail: String? public let bodyText: String? public init( href: String?, loginRequired: Bool, workspacePicker: Bool, cloudflareInterstitial: Bool, signedInEmail: String?, bodyText: String?) { self.href = href self.loginRequired = loginRequired self.workspacePicker = workspacePicker self.cloudflareInterstitial = cloudflareInterstitial self.signedInEmail = signedInEmail self.bodyText = bodyText } } nonisolated static func shouldPreserveLoadedPageAfterProbe(_ result: ProbeResult) -> Bool { guard !result.loginRequired, !result.workspacePicker, !result.cloudflareInterstitial else { return false } guard self.isUsageRoute(result.href) else { return false } guard let signedInEmail = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), !signedInEmail.isEmpty else { return false } return true } private nonisolated static func sleepForDashboardPoll(_ duration: Duration) async throws { try? await Task.sleep(for: duration) try Task.checkCancellation() } public func loadLatestDashboard( accountEmail: String?, cacheScope: CookieHeaderCache.Scope? = nil, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail, scope: cacheScope) return try await self.loadLatestDashboard( websiteDataStore: store, logger: logger, debugDumpHTML: debugDumpHTML, allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } public func loadLatestDashboard( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let preflight = try await Self.fetchDashboardAPIPreflight( websiteDataStore: websiteDataStore, deadline: deadline, logger: { logger?($0) }) try Task.checkCancellation() let (apiData, verifiedSignedInEmail) = (preflight.apiData, preflight.verifiedSignedInEmail) let remainingTimeout = try Self.requiredRemainingTimeout(until: deadline) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, timeout: remainingTimeout, allowNavigationTimeoutRetry: allowNavigationTimeoutRetry) defer { lease.release() } let webView = lease.webView let log = lease.log var lastBody: String? var lastHref: String? var lastFlags: (loginRequired: Bool, workspacePicker: Bool, cloudflare: Bool)? var codeReviewFirstSeenAt: Date? var anyDashboardSignalAt: Date? var creditsHeaderVisibleAt: Date? var usageBreakdownErrorFirstSeenAt: Date? var lastUsageBreakdownDebug: String? var lastUsageBreakdownError: String? var lastCreditsPurchaseURL: String? while Date() < deadline { try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody if scrape.href != lastHref || lastFlags?.loginRequired != scrape.loginRequired || lastFlags?.workspacePicker != scrape.workspacePicker || lastFlags?.cloudflare != scrape.cloudflareInterstitial { lastHref = scrape.href lastFlags = (scrape.loginRequired, scrape.workspacePicker, scrape.cloudflareInterstitial) let href = scrape.href ?? "nil" log( "href=\(href) login=\(scrape.loginRequired) " + "workspace=\(scrape.workspacePicker) cloudflare=\(scrape.cloudflareInterstitial)") } if scrape.workspacePicker { try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } try await self.handleBlockingScrapeState( scrape, webView: webView, debugDumpHTML: debugDumpHTML, logger: log) // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if Self.shouldReloadUsageRoute(scrape) { _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } let dashboardData = Self.parseDashboardScrape( scrape, apiData: apiData, verifiedSignedInEmail: verifiedSignedInEmail) let codeReview = dashboardData.codeReview let events = dashboardData.events let usageBreakdown = dashboardData.usageBreakdown let hasDashboardPageSignal = dashboardData.hasDashboardPageSignal let hasReturnableData = dashboardData.hasReturnableData if codeReview != nil, codeReviewFirstSeenAt == nil { codeReviewFirstSeenAt = Date() } if anyDashboardSignalAt == nil, hasDashboardPageSignal { anyDashboardSignalAt = Date() } if codeReview != nil, usageBreakdown.isEmpty, let debug = scrape.usageBreakdownDebug, !debug.isEmpty, debug != lastUsageBreakdownDebug { lastUsageBreakdownDebug = debug log("usage breakdown debug: \(debug)") } Self.updateUsageBreakdownErrorState( usageBreakdown: usageBreakdown, error: scrape.usageBreakdownError, firstSeenAt: &usageBreakdownErrorFirstSeenAt, lastError: &lastUsageBreakdownError, logger: log) if let purchaseURL = scrape.creditsPurchaseURL, purchaseURL != lastCreditsPurchaseURL { lastCreditsPurchaseURL = purchaseURL log("credits purchase url: \(purchaseURL)") } if events.isEmpty, hasReturnableData, hasDashboardPageSignal { log( "credits header present=\(scrape.creditsHeaderPresent) " + "inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " + "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { log("scrollIntoView(Credits usage history) requested; waiting…") try await Self.sleepForDashboardPoll(.milliseconds(600)) continue } // Avoid returning early when the usage breakdown chart hydrates before the (often virtualized) // credits table. When we detect a dashboard signal, give credits history a moment to appear. if scrape.creditsHeaderPresent, scrape.creditsHeaderInViewport, creditsHeaderVisibleAt == nil { creditsHeaderVisibleAt = Date() } if Self.shouldWaitForCreditsHistory(.init( now: Date(), anyDashboardSignalAt: anyDashboardSignalAt, creditsHeaderVisibleAt: creditsHeaderVisibleAt, creditsHeaderPresent: scrape.creditsHeaderPresent, creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } if hasReturnableData, hasDashboardPageSignal { if usageBreakdown.isEmpty, let error = scrape.usageBreakdownError, !error.isEmpty, Self.shouldWaitForUsageBreakdownRecovery(.init( now: Date(), errorFirstSeenAt: usageBreakdownErrorFirstSeenAt)) { try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } // The usage breakdown chart is hydrated asynchronously. When code review is already present, // give it a moment to populate so the menu can show it. if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } return Self.makeDashboardSnapshot(.init( signedInEmail: dashboardData.signedInEmail, scrape: scrape, codeReview: codeReview, codeReviewLimit: dashboardData.codeReviewLimit, events: events, breakdown: dashboardData.breakdown, usageBreakdown: usageBreakdown, rateLimits: dashboardData.rateLimits, extraRateWindows: dashboardData.extraRateWindows, creditsRemaining: dashboardData.creditsRemaining, codexCreditLimit: dashboardData.codexCreditLimit, accountPlan: dashboardData.accountPlan)) } try await Self.sleepForDashboardPoll(.milliseconds(500)) } if debugDumpHTML, let html = try? await self.fetchDebugHTML(webView: webView) { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } throw FetchError.noDashboardData(body: lastUsageBreakdownError ?? lastBody ?? "") } public func clearSessionData( accountEmail: String?, cacheScope: CookieHeaderCache.Scope? = nil) async { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail, scope: cacheScope) OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: accountEmail, scope: cacheScope) } public static func evictAllCachedWebViews() { OpenAIDashboardWebViewCache.shared.evictAll() } public static func evictIdleCachedWebViews() { OpenAIDashboardWebViewCache.shared.evictIdle() } #if DEBUG public static func seedCachedWebViewsForMemoryPressureProof() { OpenAIDashboardWebViewCache.shared.clearAllForTesting() OpenAIDashboardWebViewCache.shared.cacheEntryForTesting(websiteDataStore: .nonPersistent()) OpenAIDashboardWebViewCache.shared.cacheEntryForTesting(websiteDataStore: .nonPersistent(), isBusy: true) } public static func cachedWebViewCountForTesting() -> Int { OpenAIDashboardWebViewCache.shared.entryCount } #endif public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, timeout: TimeInterval = 30, preserveLoadedPageForReuse: Bool = false) async throws -> ProbeResult { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, timeout: Self.remainingTimeout(until: deadline), preserveLoadedPageOnRelease: preserveLoadedPageForReuse) defer { lease.release() } let webView = lease.webView let log = lease.log var lastBody: String? var lastHref: String? var usageRouteSeenAt: Date? var dashboardSignalSeenAt: Date? while Date() < deadline { try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHref = scrape.href ?? lastHref if scrape.workspacePicker { try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } Self.logBlockingStateIfNeeded(scrape, logger: log) try Self.throwIfBlockingScrapeState(scrape) if Self.shouldReloadUsageRoute(scrape) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } let normalizedEmail = scrape.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let bodyText = scrape.bodyText ?? "" let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) let hasDashboardSignal = normalizedEmail?.isEmpty == false || !scrape.rows.isEmpty || !scrape.usageBreakdown.isEmpty || scrape.creditsHeaderPresent || OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) != nil || OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) != nil || rateLimits.primary != nil || rateLimits.secondary != nil if usageRouteSeenAt == nil { usageRouteSeenAt = Date() } if hasDashboardSignal, dashboardSignalSeenAt == nil { dashboardSignalSeenAt = Date() } if Self.shouldWaitForProbeReadiness(.init( now: Date(), usageRouteSeenAt: usageRouteSeenAt, dashboardSignalSeenAt: dashboardSignalSeenAt, signedInEmail: normalizedEmail, hasDashboardSignal: hasDashboardSignal)) { try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } let result = ProbeResult( href: scrape.href, loginRequired: scrape.loginRequired, workspacePicker: scrape.workspacePicker, cloudflareInterstitial: scrape.cloudflareInterstitial, signedInEmail: normalizedEmail, bodyText: scrape.bodyText) lease.setPreserveLoadedPageOnRelease( preserveLoadedPageForReuse && Self.shouldPreserveLoadedPageAfterProbe(result)) return result } log("Probe timed out (href=\(lastHref ?? "nil"))") let result = ProbeResult( href: lastHref, loginRequired: false, workspacePicker: false, cloudflareInterstitial: false, signedInEmail: nil, bodyText: lastBody) lease.setPreserveLoadedPageOnRelease(false) return result } // MARK: - JS scrape private struct ScrapeResult { let loginRequired: Bool let workspacePicker: Bool let cloudflareInterstitial: Bool let href: String? let bodyText: String? let signedInEmail: String? let authStatus: String? let accountPlan: String? let creditsPurchaseURL: String? let rows: [[String]] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdownDebug: String? let usageBreakdownError: String? let scrollY: Double let scrollHeight: Double let viewportHeight: Double let creditsHeaderPresent: Bool let creditsHeaderInViewport: Bool let didScrollToCredits: Bool } private func scrape(webView: WKWebView) async throws -> ScrapeResult { let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) guard let dict = any as? [String: Any] else { return ScrapeResult( loginRequired: true, workspacePicker: false, cloudflareInterstitial: false, href: nil, bodyText: nil, signedInEmail: nil, authStatus: nil, accountPlan: nil, creditsPurchaseURL: nil, rows: [], usageBreakdown: [], usageBreakdownDebug: nil, usageBreakdownError: nil, scrollY: 0, scrollHeight: 0, viewportHeight: 0, creditsHeaderPresent: false, creditsHeaderInViewport: false, didScrollToCredits: false) } var loginRequired = (dict["loginRequired"] as? Bool) ?? false let workspacePicker = (dict["workspacePicker"] as? Bool) ?? false let cloudflareInterstitial = (dict["cloudflareInterstitial"] as? Bool) ?? false let rows = (dict["rows"] as? [[String]]) ?? [] var usageBreakdown: [OpenAIDashboardDailyBreakdown] = [] let usageBreakdownDebug = dict["usageBreakdownDebug"] as? String let usageBreakdownError = dict["usageBreakdownError"] as? String if let raw = dict["usageBreakdownJSON"] as? String, !raw.isEmpty { do { let decoder = JSONDecoder() let decoded = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: decoded) } catch { // Best-effort parse; ignore errors to avoid blocking other dashboard data. usageBreakdown = [] } } var signedInEmail = dict["signedInEmail"] as? String signedInEmail = signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let authStatus = (dict["authStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let accountPlan = (dict["accountPlan"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) if let authStatus, !authStatus.isEmpty, authStatus.lowercased() != "logged_in" { // When logged out, the SPA can render a generic landing shell without obvious auth inputs, // so treat it as login-required and let the caller retry cookie import. loginRequired = true } return ScrapeResult( loginRequired: loginRequired, workspacePicker: workspacePicker, cloudflareInterstitial: cloudflareInterstitial, href: dict["href"] as? String, bodyText: dict["bodyText"] as? String, signedInEmail: signedInEmail, authStatus: authStatus, accountPlan: accountPlan, creditsPurchaseURL: dict["creditsPurchaseURL"] as? String, rows: rows, usageBreakdown: usageBreakdown, usageBreakdownDebug: usageBreakdownDebug, usageBreakdownError: usageBreakdownError, scrollY: (dict["scrollY"] as? NSNumber)?.doubleValue ?? 0, scrollHeight: (dict["scrollHeight"] as? NSNumber)?.doubleValue ?? 0, viewportHeight: (dict["viewportHeight"] as? NSNumber)?.doubleValue ?? 0, creditsHeaderPresent: (dict["creditsHeaderPresent"] as? Bool) ?? false, creditsHeaderInViewport: (dict["creditsHeaderInViewport"] as? Bool) ?? false, didScrollToCredits: (dict["didScrollToCredits"] as? Bool) ?? false) } private static func throwIfBlockingScrapeState(_ scrape: ScrapeResult) throws { if scrape.loginRequired { throw FetchError.loginRequired } if scrape.cloudflareInterstitial { throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } } private func fetchDebugHTML(webView: WKWebView) async throws -> String? { try await webView.evaluateJavaScript( "document.documentElement ? String(document.documentElement.outerHTML || '') : ''") as? String } private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, timeout: TimeInterval, allowNavigationTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( websiteDataStore: websiteDataStore, usageURL: self.usageURL, logger: logger, navigationTimeout: timeout, allowTimeoutRetry: allowNavigationTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } nonisolated static func sanitizedTimeout(_ timeout: TimeInterval) -> TimeInterval { guard timeout.isFinite, timeout > 0 else { return 1 } return timeout } nonisolated static func deadline(startingAt start: Date, timeout: TimeInterval) -> Date { start.addingTimeInterval(self.sanitizedTimeout(timeout)) } nonisolated static func remainingTimeout(until deadline: Date, now: Date = Date()) -> TimeInterval { max(0, deadline.timeIntervalSince(now)) } nonisolated static func isUsageRoute(_ href: String?) -> Bool { guard let href, !href.isEmpty else { return false } let path = (URL(string: href)?.path ?? href) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) return path.hasSuffix("codex/settings/usage") || path.hasSuffix("codex/cloud/settings/usage") || path.hasSuffix("codex/settings/analytics") || path.hasSuffix("codex/cloud/settings/analytics") } nonisolated static func shouldReloadUsageRoute( href: String?, loginRequired: Bool, workspacePicker: Bool, cloudflareInterstitial: Bool) -> Bool { guard !workspacePicker, !loginRequired, !cloudflareInterstitial else { return false } guard let href else { return false } return !self.isUsageRoute(href) } private nonisolated static func shouldReloadUsageRoute(_ scrape: ScrapeResult) -> Bool { self.shouldReloadUsageRoute( href: scrape.href, loginRequired: scrape.loginRequired, workspacePicker: scrape.workspacePicker, cloudflareInterstitial: scrape.cloudflareInterstitial) } nonisolated static func usageURLRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") return request } nonisolated static func dashboardAPIData(from response: CodexUsageResponse) -> DashboardAPIData { DashboardAPIData( primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow), secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow), extraRateWindows: CodexAdditionalRateLimitMapper.extraRateWindows( from: response.additionalRateLimits), creditsRemaining: response.credits?.balance, codexCreditLimit: (response.individualLimit ?? response.rateLimit?.individualLimit)? .codexCreditLimitSnapshot(updatedAt: Date()), accountPlan: response.planType?.rawValue) } private static func fetchDashboardAPIPreflight( websiteDataStore: WKWebsiteDataStore, deadline: Date, logger: @escaping (String) -> Void) async throws -> (apiData: DashboardAPIData?, verifiedSignedInEmail: String?) { let cookieHeader = try await self.chatGPTCookieHeader(in: websiteDataStore, deadline: deadline) let apiData = await self.fetchDashboardUsageAPI( cookieHeader: cookieHeader, deadline: deadline, logger: logger) let verifiedEmail: String? = if apiData?.hasUsageData == true { await self.fetchSignedInEmailFromAPI( cookieHeader: cookieHeader, deadline: deadline, logger: logger) } else { nil } if apiData?.hasUsageData == true, verifiedEmail != nil { logger("usage api supplied verified dashboard data; continuing WebView scrape") } return (apiData, verifiedEmail) } private static func fetchDashboardUsageAPI( websiteDataStore: WKWebsiteDataStore, logger: @escaping (String) -> Void) async -> DashboardAPIData? { guard let cookieHeader = try? await self.chatGPTCookieHeader(in: websiteDataStore, deadline: nil) else { return nil } return await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, deadline: nil, logger: logger) } static func fetchDashboardUsageAPI( cookieHeader: String, deadline: Date?, logger: @escaping (String) -> Void) async -> DashboardAPIData? { guard !cookieHeader.isEmpty else { return nil } let remaining = deadline.map { self.remainingTimeout(until: $0) } ?? 4 guard remaining > 0 else { return nil } do { let (data, response) = try await CodexAuthenticatedHTTPTransport.current.data( for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader, timeout: min(4, remaining))) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("usage api status=\(status)") guard status >= 200, status < 300 else { return nil } let decoded = try JSONDecoder().decode(CodexUsageResponse.self, from: data) let result = self.dashboardAPIData(from: decoded) if result.hasUsageData { logger("usage api supplied language-independent rate/credit data") } return result } catch { logger("usage api unavailable: \(error.localizedDescription)") return nil } } static func fetchSignedInEmailFromAPI( cookieHeader: String, deadline: Date?, logger: @escaping (String) -> Void) async -> String? { guard !cookieHeader.isEmpty else { return nil } let endpoints = [ URL(string: "https://chatgpt.com/backend-api/me"), URL(string: "https://chatgpt.com/api/auth/session"), ].compactMap(\.self) for url in endpoints { let remaining = deadline.map { self.remainingTimeout(until: $0) } ?? 2 guard remaining > 0 else { return nil } do { let (data, response) = try await CodexAuthenticatedHTTPTransport.current.data( for: self.dashboardIdentityAPIRequest( url: url, cookieHeader: cookieHeader, timeout: min(2, remaining))) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("identity api \(url.path) status=\(status)") guard status >= 200, status < 300 else { continue } if let email = self.findFirstEmail(inJSONData: data) { return email.trimmingCharacters(in: .whitespacesAndNewlines) } } catch { logger("identity api \(url.path) unavailable: \(error.localizedDescription)") } } return nil } private static func chatGPTCookieHeader(in store: WKWebsiteDataStore, deadline: Date?) async throws -> String { let cookies = try await OpenAIDashboardBrowserCookieImporter.runBoundedValueCallback( deadline: deadline) { completion in store.httpCookieStore.getAllCookies(completion) } return cookies .filter { $0.domain.lowercased().contains("chatgpt.com") } .map { "\($0.name)=\($0.value)" } .joined(separator: "; ") } nonisolated 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 current = queue.removeFirst() seen += 1 if let string = current as? String, string.contains("@") { return string } if let dictionary = current as? [String: Any] { for (key, value) in dictionary { if key.lowercased() == "email", let string = value as? String, string.contains("@") { return string } queue.append(value) } } else if let array = current as? [Any] { queue.append(contentsOf: array) } } return nil } private nonisolated static func rateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { guard let window else { return nil } let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) return RateWindow( usedPercent: Double(window.usedPercent), windowMinutes: window.limitWindowSeconds / 60, resetsAt: resetDate, resetDescription: UsageFormatter.resetDescription(from: resetDate)) } private nonisolated static func firstNonEmpty(_ candidates: String?...) -> String? { for candidate in candidates { let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed?.isEmpty == false { return trimmed } } return nil } private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { let stamp = Int(Date().timeIntervalSince1970) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let htmlURL = dir.appendingPathComponent("codex-openai-dashboard-\(stamp).html") do { try html.write(to: htmlURL, atomically: true, encoding: .utf8) logger("Dumped HTML: \(htmlURL.path)") } catch { logger("Failed to dump HTML: \(error.localizedDescription)") } if let bodyText, !bodyText.isEmpty { let textURL = dir.appendingPathComponent("codex-openai-dashboard-\(stamp).txt") do { try bodyText.write(to: textURL, atomically: true, encoding: .utf8) logger("Dumped text: \(textURL.path)") } catch { logger("Failed to dump text: \(error.localizedDescription)") } } } } extension OpenAIDashboardFetcher { struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? let creditsHeaderVisibleAt: Date? let creditsHeaderPresent: Bool let creditsHeaderInViewport: Bool let didScrollToCredits: Bool } nonisolated static func shouldWaitForCreditsHistory(_ context: CreditsHistoryWaitContext) -> Bool { if context.didScrollToCredits { return true } // When the header is visible but rows are still empty, wait briefly for the table to render. if context.creditsHeaderPresent, context.creditsHeaderInViewport { if let creditsHeaderVisibleAt = context.creditsHeaderVisibleAt { return context.now.timeIntervalSince(creditsHeaderVisibleAt) < 2.5 } return true } // Header not in view yet: allow a short grace period after we first detect any dashboard signal so // a scroll (or hydration) can bring the credits section into the DOM. if let anyDashboardSignalAt = context.anyDashboardSignalAt { return context.now.timeIntervalSince(anyDashboardSignalAt) < 6.5 } return false } struct ProbeReadinessContext { let now: Date let usageRouteSeenAt: Date? let dashboardSignalSeenAt: Date? let signedInEmail: String? let hasDashboardSignal: Bool } struct UsageBreakdownRecoveryContext { let now: Date let errorFirstSeenAt: Date? } nonisolated static func shouldWaitForUsageBreakdownRecovery(_ context: UsageBreakdownRecoveryContext) -> Bool { guard let errorFirstSeenAt = context.errorFirstSeenAt else { return true } return context.now.timeIntervalSince(errorFirstSeenAt) < 4.0 } nonisolated static func shouldWaitForProbeReadiness(_ context: ProbeReadinessContext) -> Bool { if let signedInEmail = context.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), !signedInEmail.isEmpty { return false } if context.hasDashboardSignal { if let dashboardSignalSeenAt = context.dashboardSignalSeenAt { return context.now.timeIntervalSince(dashboardSignalSeenAt) < 2.0 } return true } if let usageRouteSeenAt = context.usageRouteSeenAt { return context.now.timeIntervalSince(usageRouteSeenAt) < 2.0 } return false } nonisolated static func updateUsageBreakdownErrorState( usageBreakdown: [OpenAIDashboardDailyBreakdown], error: String?, firstSeenAt: inout Date?, lastError: inout String?, now: Date = Date(), logger: (String) -> Void) { guard usageBreakdown.isEmpty, let error, !error.isEmpty else { firstSeenAt = nil return } if firstSeenAt == nil { firstSeenAt = now } guard error != lastError else { return } lastError = error logger("usage breakdown error: \(error)") } } extension OpenAIDashboardFetcher { nonisolated static func requiredRemainingTimeout( until deadline: Date, now: Date = Date()) throws -> TimeInterval { let remaining = self.remainingTimeout(until: deadline, now: now) guard remaining > 0 else { throw URLError(.timedOut) } return remaining } nonisolated static func dashboardUsageAPIRequest( cookieHeader: String, timeout: TimeInterval = 4) -> URLRequest { var request = URLRequest( url: Self.dashboardUsageAPIURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) request.httpMethod = "GET" request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") return request } nonisolated static func dashboardIdentityAPIRequest( url: URL, cookieHeader: String, timeout: TimeInterval = 2) -> URLRequest { var request = URLRequest( url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) request.httpMethod = "GET" request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") return request } private static func logBlockingStateIfNeeded(_ scrape: ScrapeResult, logger: (String) -> Void) { guard scrape.loginRequired || scrape.cloudflareInterstitial else { return } let route = self.isUsageRoute(scrape.href) ? "usage" : "other" logger( "blocking state before route reload route=\(route) " + "login=\(scrape.loginRequired) cloudflare=\(scrape.cloudflareInterstitial)") } private func handleBlockingScrapeState( _ scrape: ScrapeResult, webView: WKWebView, debugDumpHTML: Bool, logger: (String) -> Void) async throws { if debugDumpHTML, scrape.loginRequired || scrape.cloudflareInterstitial, let html = try? await self.fetchDebugHTML(webView: webView) { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) } Self.logBlockingStateIfNeeded(scrape, logger: logger) try Self.throwIfBlockingScrapeState(scrape) } } #else import Foundation @MainActor public struct OpenAIDashboardFetcher { public enum FetchError: LocalizedError { case loginRequired case noDashboardData(body: String) public var errorDescription: String? { switch self { case .loginRequired: "OpenAI web access requires login." case let .noDashboardData(body): "OpenAI dashboard data not found. Body sample: \(body.prefix(200))" } } } public init() {} public func loadLatestDashboard( accountEmail _: String?, cacheScope _: CookieHeaderCache.Scope? = nil, logger _: ((String) -> Void)? = nil, debugDumpHTML _: Bool = false, allowNavigationTimeoutRetry _: Bool = true, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") } } #endif