1092 lines · 44.8 KB · Swift
RawDownload
#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.