Files
steipete--codexbar/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
T
2026-07-13 12:22:33 +08:00

1093 lines
45 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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