#if os(macOS)import CoreGraphicsimport Foundationimport WebKit @MainActorpublic 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(Lines 1–500 of 1092. Load more
#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(