24af908c9e
Keep ChatGPT dashboard fetches API-first, scrape the page only when history is missing, and destroy the offscreen WebView immediately so WebKit helper processes cannot stay resident.
1632 lines
72 KiB
Swift
1632 lines
72 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
|
|
struct OpenAIWebRefreshGateContext {
|
|
let force: Bool
|
|
let accountDidChange: Bool
|
|
let lastError: String?
|
|
let lastSnapshotAt: Date?
|
|
let lastAttemptAt: Date?
|
|
let now: Date
|
|
let refreshInterval: TimeInterval
|
|
}
|
|
|
|
struct OpenAIWebRefreshPolicyContext {
|
|
let accessEnabled: Bool
|
|
let batterySaverEnabled: Bool
|
|
let force: Bool
|
|
let refreshPhase: ProviderRefreshPhase
|
|
}
|
|
|
|
// MARK: - OpenAI web lifecycle
|
|
|
|
extension UsageStore {
|
|
private struct OpenAIDashboardRefreshContext {
|
|
let targetEmail: String?
|
|
let allowCurrentSnapshotFallback: Bool
|
|
let expectedGuard: CodexAccountScopedRefreshGuard?
|
|
let refreshTaskToken: UUID
|
|
let allowCodexUsageBackfill: Bool
|
|
let force: Bool
|
|
}
|
|
|
|
private struct OpenAIDashboardCookieImportRequest {
|
|
let normalizedTarget: String?
|
|
let allowAnyAccount: Bool
|
|
let cookieSource: ProviderCookieSource
|
|
let cacheScope: CookieHeaderCache.Scope?
|
|
let preferCachedCookieHeader: Bool?
|
|
let force: Bool
|
|
}
|
|
|
|
private static let openAIWebRefreshMultiplier: TimeInterval = 5
|
|
private static let openAIWebPrimaryFetchTimeout: TimeInterval = 25
|
|
private static let openAIWebRetryFetchTimeout: TimeInterval = 8
|
|
private static let openAIWebPostImportFetchTimeout: TimeInterval = 25
|
|
|
|
static func openAIWebDashboardFetchTimeout(didImportCookies: Bool) -> TimeInterval {
|
|
didImportCookies ? self.openAIWebPostImportFetchTimeout : self.openAIWebPrimaryFetchTimeout
|
|
}
|
|
|
|
static func openAIWebRetryDashboardFetchTimeout(afterCookieImport: Bool) -> TimeInterval {
|
|
afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout
|
|
}
|
|
|
|
nonisolated static func refreshPhase(
|
|
hasCompletedInitialRefresh: Bool) -> ProviderRefreshPhase
|
|
{
|
|
hasCompletedInitialRefresh ? .regular : .startup
|
|
}
|
|
|
|
nonisolated static func openAIWebRefreshPhase(
|
|
providerRefreshPhase: ProviderRefreshPhase,
|
|
startupConnectivityRetryAttempt: Int?) -> ProviderRefreshPhase
|
|
{
|
|
startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup
|
|
}
|
|
|
|
func openAIWebRefreshIntervalSeconds() -> TimeInterval {
|
|
let base = max(self.normalRefreshIntervalForHeuristics() ?? 0, 120)
|
|
return base * Self.openAIWebRefreshMultiplier
|
|
}
|
|
|
|
func requestOpenAIDashboardRefreshIfStale(reason: String) {
|
|
// Provider-specific by design: the OpenAI WKWebView session lifecycle attaches only to Codex consumer usage.
|
|
guard self.isEnabled(.codex),
|
|
self.settings.openAIWebAccessEnabled,
|
|
self.settings.codexCookieSource.isEnabled
|
|
else { return }
|
|
let now = Date()
|
|
let refreshInterval = self.openAIWebRefreshIntervalSeconds()
|
|
let dashboard = self.openAIDashboard ?? self.lastOpenAIDashboardSnapshot
|
|
let lastUpdatedAt = dashboard?.updatedAt
|
|
let needsMenuHistoryRefresh = dashboard?.dailyBreakdown.isEmpty == true &&
|
|
dashboard?.usageBreakdown.isEmpty == true
|
|
if needsMenuHistoryRefresh,
|
|
Self.shouldSkipOpenAIWebEmptyHistoryRetry(.init(
|
|
force: false,
|
|
accountDidChange: self.openAIWebAccountDidChange,
|
|
lastError: self.lastOpenAIDashboardError,
|
|
lastSnapshotAt: lastUpdatedAt,
|
|
lastAttemptAt: self.lastOpenAIDashboardAttemptAt,
|
|
now: now,
|
|
refreshInterval: refreshInterval))
|
|
{
|
|
return
|
|
}
|
|
if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval, !needsMenuHistoryRefresh {
|
|
return
|
|
}
|
|
let stamp = now.formatted(date: .abbreviated, time: .shortened)
|
|
self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)")
|
|
let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest(
|
|
batterySaverEnabled: self.settings.effectiveOpenAIWebBatterySaverEnabled) || needsMenuHistoryRefresh
|
|
self.openAIWebLogger.info(
|
|
"OpenAI web stale refresh gate",
|
|
metadata: [
|
|
"reason": reason,
|
|
"force": forceRefresh ? "1" : "0",
|
|
"batterySaverEnabled": self.settings.effectiveOpenAIWebBatterySaverEnabled ? "1" : "0",
|
|
"interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background",
|
|
])
|
|
let expectedGuard = self.freshCodexOpenAIWebRefreshGuard()
|
|
Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) }
|
|
}
|
|
|
|
func applyOpenAIDashboard(
|
|
_ dash: OpenAIDashboardSnapshot,
|
|
targetEmail: String?,
|
|
expectedGuard: CodexAccountScopedRefreshGuard? = nil,
|
|
refreshTaskToken: UUID? = nil,
|
|
allowCodexUsageBackfill: Bool = true) async
|
|
{
|
|
guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return }
|
|
self.settings.invalidateCodexAccountReconciliationSnapshotCache()
|
|
let authority = self.evaluateCodexDashboardAuthority(
|
|
dashboard: dash,
|
|
sourceKind: .liveWeb,
|
|
routingTargetEmail: targetEmail)
|
|
if let expectedGuard {
|
|
let shouldApply = switch authority.decision.disposition {
|
|
case .attach:
|
|
self.shouldApplyOpenAIDashboardRefreshGuard(
|
|
expectedGuard: expectedGuard,
|
|
routingTargetEmail: targetEmail)
|
|
case .displayOnly, .failClosed:
|
|
self.shouldApplyOpenAIDashboardPolicyResult(
|
|
expectedGuard: expectedGuard,
|
|
routingTargetEmail: targetEmail)
|
|
}
|
|
guard shouldApply else { return }
|
|
}
|
|
|
|
let attachedAccountEmail = self.codexDashboardAttachmentEmail(from: authority.input)
|
|
self.reconcileCodexPublishedUsageOwner(with: self.freshCodexAccountScopedRefreshGuard(
|
|
preferCurrentSnapshot: true,
|
|
allowLastKnownLiveFallback: false))
|
|
|
|
await self.applyOpenAIDashboardAuthorityDecision(
|
|
authority.decision,
|
|
dashboard: dash,
|
|
authorityInput: authority.input,
|
|
attachedAccountEmail: attachedAccountEmail,
|
|
allowCodexUsageBackfill: allowCodexUsageBackfill)
|
|
}
|
|
|
|
func applyOpenAIDashboardFailure(
|
|
message: String,
|
|
expectedGuard: CodexAccountScopedRefreshGuard? = nil,
|
|
refreshTaskToken: UUID? = nil,
|
|
routingTargetEmail: String? = nil) async
|
|
{
|
|
guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return }
|
|
if let expectedGuard,
|
|
!self.shouldApplyOpenAIWebNonSuccessResult(
|
|
expectedGuard: expectedGuard,
|
|
routingTargetEmail: routingTargetEmail)
|
|
{
|
|
return
|
|
}
|
|
if self.openAIWebManagedTargetStoreIsUnreadable() {
|
|
await self.failClosedRefreshForUnreadableManagedCodexStore()
|
|
return
|
|
}
|
|
if self.openAIWebManagedTargetIsMissing() {
|
|
await self.failClosedRefreshForMissingManagedCodexTarget()
|
|
return
|
|
}
|
|
if self.openAIWebProfileTargetEmailIsMissing() {
|
|
await self.failClosedRefreshForMissingProfileCodexTarget()
|
|
return
|
|
}
|
|
|
|
OpenAIDashboardFetcher.evictAllCachedWebViews()
|
|
await MainActor.run {
|
|
if let cached = self.lastOpenAIDashboardSnapshot {
|
|
self.openAIDashboard = cached
|
|
self.openAIDashboardAttachmentAuthorized = self.lastOpenAIDashboardAttachmentAuthorized
|
|
let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
|
|
self.lastOpenAIDashboardError =
|
|
"Last OpenAI dashboard refresh failed: \(message). Cached values from \(stamp)."
|
|
} else {
|
|
self.lastOpenAIDashboardError = message
|
|
self.openAIDashboard = nil
|
|
self.openAIDashboardAttachmentAuthorized = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyOpenAIDashboardLoginRequiredFailure(
|
|
expectedGuard: CodexAccountScopedRefreshGuard? = nil,
|
|
refreshTaskToken: UUID? = nil,
|
|
routingTargetEmail: String? = nil) async
|
|
{
|
|
guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return }
|
|
if let expectedGuard,
|
|
!self.shouldApplyOpenAIWebNonSuccessResult(
|
|
expectedGuard: expectedGuard,
|
|
routingTargetEmail: routingTargetEmail)
|
|
{
|
|
return
|
|
}
|
|
if self.openAIWebManagedTargetStoreIsUnreadable() {
|
|
await self.failClosedRefreshForUnreadableManagedCodexStore()
|
|
return
|
|
}
|
|
if self.openAIWebManagedTargetIsMissing() {
|
|
await self.failClosedRefreshForMissingManagedCodexTarget()
|
|
return
|
|
}
|
|
if self.openAIWebProfileTargetEmailIsMissing() {
|
|
await self.failClosedRefreshForMissingProfileCodexTarget()
|
|
return
|
|
}
|
|
|
|
OpenAIDashboardFetcher.evictAllCachedWebViews()
|
|
await MainActor.run {
|
|
self.lastOpenAIDashboardError = [
|
|
"OpenAI web access requires a signed-in chatgpt.com session.",
|
|
"Sign in using \(self.codexBrowserCookieOrder.loginHint), " +
|
|
"then update OpenAI cookies in Providers → Codex.",
|
|
].joined(separator: " ")
|
|
self.openAIDashboard = self.lastOpenAIDashboardSnapshot
|
|
self.openAIDashboardAttachmentAuthorized = self.lastOpenAIDashboardAttachmentAuthorized
|
|
self.openAIDashboardRequiresLogin = true
|
|
}
|
|
}
|
|
|
|
private func failClosedOpenAIDashboardSnapshot() {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
}
|
|
|
|
private func applyOpenAIDashboardAuthorityDecision(
|
|
_ decision: CodexDashboardAuthorityDecision,
|
|
dashboard: OpenAIDashboardSnapshot,
|
|
authorityInput: CodexDashboardAuthorityInput,
|
|
attachedAccountEmail: String?,
|
|
allowCodexUsageBackfill: Bool) async
|
|
{
|
|
switch decision.disposition {
|
|
case .attach:
|
|
self.openAIDashboard = dashboard
|
|
self.openAIDashboardAttachmentAuthorized = true
|
|
self.lastOpenAIDashboardSnapshot = dashboard
|
|
self.lastOpenAIDashboardAttachmentAuthorized = true
|
|
self.lastOpenAIDashboardError = nil
|
|
self.openAIDashboardRequiresLogin = false
|
|
|
|
// Provider-specific by design: an authorized OpenAI dashboard attaches metadata/backfill to Codex usage.
|
|
if let currentUsage = self.snapshots[.codex] {
|
|
self.snapshots[.codex] = currentUsage.withSubscriptionMetadata(
|
|
expiresAt: dashboard.subscriptionExpiresAt,
|
|
renewsAt: dashboard.subscriptionRenewsAt)
|
|
}
|
|
|
|
if decision.allowedEffects.contains(.usageBackfill),
|
|
allowCodexUsageBackfill,
|
|
self.snapshots[.codex] == nil,
|
|
let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail),
|
|
CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: usage) == .publishInitial
|
|
{
|
|
self.snapshots[.codex] = usage
|
|
self.errors[.codex] = nil
|
|
self.failureGates[.codex]?.recordSuccess()
|
|
self.lastSourceLabels[.codex] = "openai-web"
|
|
self.lastCodexUsagePublicationGuard = self.currentCodexAccountScopedRefreshGuard(
|
|
preferCurrentSnapshot: true,
|
|
allowLastKnownLiveFallback: false)
|
|
}
|
|
|
|
if decision.allowedEffects.contains(.creditsAttachment),
|
|
self.credits == nil,
|
|
let credits = dashboard.toCreditsSnapshot()
|
|
{
|
|
self.credits = credits
|
|
self.lastCreditsSnapshot = credits
|
|
self.lastCreditsSnapshotAccountKey = Self.normalizeCodexAccountScopedKey(attachedAccountEmail)
|
|
self.lastCreditsSource = .dashboardWeb
|
|
self.lastCreditsError = nil
|
|
self.creditsFailureStreak = 0
|
|
}
|
|
|
|
if decision.allowedEffects.contains(.refreshGuardSeed) {
|
|
self.seedCodexAccountScopedRefreshGuard(accountEmail: attachedAccountEmail)
|
|
}
|
|
|
|
if let attachedAccountEmail, !attachedAccountEmail.isEmpty {
|
|
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
|
accountEmail: attachedAccountEmail,
|
|
snapshot: dashboard))
|
|
}
|
|
|
|
if decision.allowedEffects.contains(.historicalBackfill) {
|
|
self.backfillCodexHistoricalFromDashboardIfNeeded(
|
|
dashboard,
|
|
authorityDecision: decision,
|
|
attachedAccountEmail: attachedAccountEmail)
|
|
}
|
|
|
|
case .displayOnly:
|
|
self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: true)
|
|
self.openAIDashboard = dashboard
|
|
self.openAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardSnapshot = dashboard
|
|
self.lastOpenAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardError = nil
|
|
self.openAIDashboardRequiresLogin = false
|
|
|
|
case .failClosed:
|
|
self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: false)
|
|
self.lastOpenAIDashboardError = self.openAIDashboardPolicyFailureMessage(
|
|
for: decision,
|
|
authorityInput: authorityInput)
|
|
self.openAIDashboardRequiresLogin = true
|
|
}
|
|
}
|
|
|
|
private func applyOpenAIDashboardCleanup(
|
|
_ cleanup: Set<CodexDashboardCleanup>,
|
|
preserveVisibleDashboard: Bool)
|
|
{
|
|
if cleanup.contains(.dashboardDerivedUsage) {
|
|
self.clearDashboardDerivedCodexUsageIfNeeded()
|
|
}
|
|
if cleanup.contains(.dashboardDerivedCredits) {
|
|
self.clearDashboardDerivedCreditsIfNeeded()
|
|
}
|
|
if cleanup.contains(.dashboardRefreshGuardSeed) {
|
|
self.clearDashboardRefreshGuardSeedIfNeeded()
|
|
}
|
|
if cleanup.contains(.dashboardCache) {
|
|
OpenAIDashboardCacheStore.clear()
|
|
}
|
|
if cleanup.contains(.dashboardSnapshot), !preserveVisibleDashboard {
|
|
self.openAIDashboard = nil
|
|
self.openAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardSnapshot = nil
|
|
self.lastOpenAIDashboardAttachmentAuthorized = false
|
|
}
|
|
}
|
|
|
|
private func clearDashboardDerivedCodexUsageIfNeeded() {
|
|
guard self.lastSourceLabels[.codex] == "openai-web" else { return }
|
|
self.clearCodexPublishedUsageState()
|
|
}
|
|
|
|
private func clearDashboardDerivedCreditsIfNeeded() {
|
|
guard self.lastCreditsSource == .dashboardWeb else { return }
|
|
self.credits = nil
|
|
self.lastCreditsError = nil
|
|
self.lastCreditsSnapshot = nil
|
|
self.lastCreditsSnapshotAccountKey = nil
|
|
self.lastCreditsSource = .none
|
|
self.creditsFailureStreak = 0
|
|
}
|
|
|
|
private func clearDashboardRefreshGuardSeedIfNeeded() {
|
|
let currentGuard = self.currentCodexAccountScopedRefreshGuard(
|
|
preferCurrentSnapshot: false,
|
|
allowLastKnownLiveFallback: false)
|
|
if self.snapshots[.codex] != nil,
|
|
self.lastCodexUsagePublicationGuard.map({
|
|
!Self.codexScopedRefreshGuardsMatchAccount($0, currentGuard)
|
|
}) ?? true
|
|
{
|
|
self.clearCodexPublishedUsageState()
|
|
}
|
|
self.lastCodexAccountScopedRefreshGuard = currentGuard
|
|
}
|
|
|
|
private func openAIDashboardPolicyFailureMessage(
|
|
for decision: CodexDashboardAuthorityDecision,
|
|
authorityInput: CodexDashboardAuthorityInput) -> String
|
|
{
|
|
switch decision.reason {
|
|
case let .wrongEmail(expected, actual):
|
|
[
|
|
"OpenAI dashboard signed in as \(actual ?? "unknown"), but Codex uses \(expected ?? "unknown").",
|
|
"Switch accounts in your browser and update OpenAI cookies in Providers → Codex.",
|
|
].joined(separator: " ")
|
|
case let .sameEmailAmbiguity(email):
|
|
"OpenAI dashboard ownership is ambiguous for \(email); Codex will not attach dashboard data."
|
|
case .missingDashboardSignedInEmail:
|
|
"OpenAI dashboard did not report a signed-in account. Refresh OpenAI cookies and try again."
|
|
case .unresolvedWithoutTrustedEvidence:
|
|
"OpenAI dashboard ownership could not be verified for the active Codex account."
|
|
case .providerAccountMissingScopedEmail:
|
|
"Codex account ownership could not be verified because the scoped email is unavailable."
|
|
case .providerAccountLacksExactOwnershipProof:
|
|
[
|
|
"OpenAI dashboard ownership could not be matched to the active Codex account.",
|
|
"Refresh Codex account data, then retry OpenAI web access.",
|
|
].joined(separator: " ")
|
|
case .exactProviderAccountMatch,
|
|
.trustedEmailMatchNoCompetingOwner,
|
|
.trustedContinuityNoCompetingOwner:
|
|
"OpenAI dashboard ownership policy blocked this dashboard."
|
|
}
|
|
}
|
|
|
|
func refreshOpenAIDashboardIfNeeded(
|
|
force: Bool = false,
|
|
expectedGuard: CodexAccountScopedRefreshGuard? = nil,
|
|
bypassCoalescing: Bool = false,
|
|
allowCodexUsageBackfill: Bool = true) async
|
|
{
|
|
self.syncOpenAIWebState()
|
|
guard self.isEnabled(.codex),
|
|
self.settings.openAIWebAccessEnabled,
|
|
self.settings.codexCookieSource.isEnabled
|
|
else { return }
|
|
if self.openAIWebManagedTargetStoreIsUnreadable() {
|
|
await self.failClosedRefreshForUnreadableManagedCodexStore()
|
|
return
|
|
}
|
|
if self.openAIWebManagedTargetIsMissing() {
|
|
await self.failClosedRefreshForMissingManagedCodexTarget()
|
|
return
|
|
}
|
|
if self.openAIWebProfileTargetEmailIsMissing() {
|
|
await self.failClosedRefreshForMissingProfileCodexTarget()
|
|
return
|
|
}
|
|
|
|
let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard?
|
|
.identity == .unresolved
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: allowCurrentSnapshotFallback,
|
|
allowLastKnownLiveFallback: expectedGuard?.identity != .unresolved)
|
|
let refreshKey = self.openAIDashboardRefreshKey(targetEmail: targetEmail, expectedGuard: expectedGuard)
|
|
if !bypassCoalescing,
|
|
let task = self.openAIDashboardRefreshTask,
|
|
self.openAIDashboardRefreshTaskKey == refreshKey
|
|
{
|
|
await task.value
|
|
return
|
|
}
|
|
if bypassCoalescing {
|
|
self.openAIDashboardBackgroundRefreshTask?.cancel()
|
|
self.openAIDashboardRefreshTask?.cancel()
|
|
}
|
|
self.handleOpenAIWebTargetEmailChangeIfNeeded(
|
|
targetEmail: targetEmail,
|
|
targetScope: self.codexCookieCacheScopeForOpenAIWeb())
|
|
|
|
let now = Date()
|
|
let minInterval = self.openAIWebRefreshIntervalSeconds()
|
|
let refreshGate = OpenAIWebRefreshGateContext(
|
|
force: force,
|
|
accountDidChange: self.openAIWebAccountDidChange,
|
|
lastError: self.lastOpenAIDashboardError,
|
|
lastSnapshotAt: self.lastOpenAIDashboardSnapshot?.updatedAt,
|
|
lastAttemptAt: self.lastOpenAIDashboardAttemptAt,
|
|
now: now,
|
|
refreshInterval: minInterval)
|
|
if Self.shouldSkipOpenAIWebRefresh(refreshGate) {
|
|
return
|
|
}
|
|
self.lastOpenAIDashboardAttemptAt = now
|
|
|
|
let taskToken = UUID()
|
|
let context = OpenAIDashboardRefreshContext(
|
|
targetEmail: targetEmail,
|
|
allowCurrentSnapshotFallback: allowCurrentSnapshotFallback,
|
|
expectedGuard: expectedGuard,
|
|
refreshTaskToken: taskToken,
|
|
allowCodexUsageBackfill: allowCodexUsageBackfill,
|
|
force: force)
|
|
let task = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.performOpenAIDashboardRefreshIfNeeded(context)
|
|
}
|
|
self.openAIDashboardRefreshTask = task
|
|
self.openAIDashboardRefreshTaskKey = refreshKey
|
|
self.openAIDashboardRefreshTaskToken = taskToken
|
|
await task.value
|
|
if self.openAIDashboardRefreshTaskToken == taskToken {
|
|
self.openAIDashboardRefreshTask = nil
|
|
self.openAIDashboardRefreshTaskKey = nil
|
|
self.openAIDashboardRefreshTaskToken = nil
|
|
}
|
|
}
|
|
|
|
func scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: CodexAccountScopedRefreshGuard? = nil) {
|
|
self.syncOpenAIWebState()
|
|
let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard?
|
|
.identity == .unresolved
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: allowCurrentSnapshotFallback,
|
|
allowLastKnownLiveFallback: expectedGuard?.identity != .unresolved)
|
|
let refreshKey = self.openAIDashboardRefreshKey(targetEmail: targetEmail, expectedGuard: expectedGuard)
|
|
if let task = self.openAIDashboardBackgroundRefreshTask,
|
|
!task.isCancelled,
|
|
self.openAIDashboardBackgroundRefreshTaskKey == refreshKey
|
|
{
|
|
return
|
|
}
|
|
|
|
if self.openAIDashboardBackgroundRefreshTaskKey != nil,
|
|
self.openAIDashboardBackgroundRefreshTaskKey != refreshKey
|
|
{
|
|
self.invalidateOpenAIDashboardRefreshTask()
|
|
}
|
|
self.openAIDashboardBackgroundRefreshTask?.cancel()
|
|
self.openAIDashboardBackgroundRefreshTaskKey = refreshKey
|
|
self.openAIDashboardBackgroundRefreshTask = Task(priority: .utility) { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer {
|
|
if self.openAIDashboardBackgroundRefreshTaskKey == refreshKey {
|
|
self.openAIDashboardBackgroundRefreshTask = nil
|
|
self.openAIDashboardBackgroundRefreshTaskKey = nil
|
|
}
|
|
}
|
|
|
|
guard !Task.isCancelled else { return }
|
|
await self.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard)
|
|
guard !Task.isCancelled else { return }
|
|
self.persistWidgetSnapshot(reason: "dashboard")
|
|
}
|
|
}
|
|
|
|
private func performOpenAIDashboardRefreshIfNeeded(_ context: OpenAIDashboardRefreshContext) async {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
self.openAIDashboardCookieImportStatus = nil
|
|
var latestCookieImportStatus: String?
|
|
if self.openAIWebDebugLines.isEmpty {
|
|
self.resetOpenAIWebDebugLog(context: "refresh")
|
|
} else {
|
|
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
self.logOpenAIWeb("[\(stamp)] OpenAI web refresh start")
|
|
}
|
|
let log: (String) -> Void = { [weak self] line in
|
|
guard let self else { return }
|
|
self.logOpenAIWeb(line)
|
|
}
|
|
|
|
do {
|
|
let normalized = context.targetEmail?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
var effectiveEmail = context.targetEmail
|
|
|
|
// Use a per-email persistent `WKWebsiteDataStore` so multiple dashboard sessions can coexist.
|
|
// Strategy:
|
|
// - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts).
|
|
// - On login-required or account mismatch, import cookies from the configured browser order and retry once.
|
|
var didImportCookiesForRefresh = false
|
|
if self.openAIWebAccountDidChange, let targetEmail = context.targetEmail, !targetEmail.isEmpty {
|
|
// On account switches, proactively re-import cookies so we don't show stale data from the previous
|
|
// user.
|
|
let imported = await self.importOpenAIDashboardCookiesIfNeeded(
|
|
targetEmail: targetEmail,
|
|
force: true)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
didImportCookiesForRefresh = true
|
|
latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus()
|
|
if await self.abortOpenAIDashboardRetryAfterImportFailure(
|
|
importedEmail: imported,
|
|
targetEmail: targetEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
cookieImportStatus: latestCookieImportStatus,
|
|
refreshTaskToken: context.refreshTaskToken)
|
|
{
|
|
self.openAIWebAccountDidChange = false
|
|
return
|
|
}
|
|
if let imported {
|
|
effectiveEmail = imported
|
|
}
|
|
self.openAIWebAccountDidChange = false
|
|
}
|
|
|
|
var dash = try await self.loadLatestOpenAIDashboard(
|
|
accountEmail: effectiveEmail,
|
|
logger: log,
|
|
allowNavigationTimeoutRetry: context.force,
|
|
timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh),
|
|
force: context.force)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
|
|
if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
|
|
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
|
|
targetEmail: context.targetEmail,
|
|
force: true)
|
|
{
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
effectiveEmail = imported
|
|
}
|
|
latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus()
|
|
dash = try await self.loadLatestOpenAIDashboard(
|
|
accountEmail: effectiveEmail,
|
|
logger: log,
|
|
allowNavigationTimeoutRetry: context.force,
|
|
timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true),
|
|
force: context.force)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
}
|
|
|
|
await self.applyOpenAIDashboard(
|
|
dash,
|
|
targetEmail: effectiveEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
allowCodexUsageBackfill: context.allowCodexUsageBackfill)
|
|
} catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.retryOpenAIDashboardAfterNoData(
|
|
body: body,
|
|
context: context,
|
|
latestCookieImportStatus: &latestCookieImportStatus,
|
|
logger: log)
|
|
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.retryOpenAIDashboardAfterLoginRequired(
|
|
context: context,
|
|
latestCookieImportStatus: &latestCookieImportStatus,
|
|
logger: log)
|
|
} catch {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
if Self.isOpenAIDashboardTimeout(error) {
|
|
await self.retryOpenAIDashboardAfterTimeout(
|
|
context: context,
|
|
latestCookieImportStatus: &latestCookieImportStatus,
|
|
logger: log)
|
|
return
|
|
}
|
|
let message = self.preferredOpenAIDashboardFailureMessage(
|
|
error: error,
|
|
targetEmail: context.targetEmail,
|
|
cookieImportStatus: latestCookieImportStatus)
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: context.targetEmail)
|
|
}
|
|
|
|
OpenAIDashboardFetcher.evictIdleCachedWebViews()
|
|
}
|
|
|
|
private func retryOpenAIDashboardAfterTimeout(
|
|
context: OpenAIDashboardRefreshContext,
|
|
latestCookieImportStatus: inout String?,
|
|
logger: @escaping (String) -> Void) async
|
|
{
|
|
if !context.force {
|
|
OpenAIDashboardFetcher.evictAllCachedWebViews()
|
|
logger("OpenAI web refresh timed out; skipping immediate background retry.")
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: L(
|
|
"OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown."),
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: context.targetEmail)
|
|
return
|
|
}
|
|
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback,
|
|
allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved)
|
|
var effectiveEmail = targetEmail
|
|
let imported = await self.importOpenAIDashboardCookiesIfNeeded(
|
|
targetEmail: targetEmail,
|
|
force: true,
|
|
preferCachedCookieHeader: true)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus()
|
|
if await self.abortOpenAIDashboardRetryAfterImportFailure(
|
|
importedEmail: imported,
|
|
targetEmail: targetEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
cookieImportStatus: latestCookieImportStatus,
|
|
refreshTaskToken: context.refreshTaskToken)
|
|
{
|
|
return
|
|
}
|
|
if let imported {
|
|
effectiveEmail = imported
|
|
}
|
|
do {
|
|
let dash = try await self.loadLatestOpenAIDashboard(
|
|
accountEmail: effectiveEmail,
|
|
logger: logger,
|
|
allowNavigationTimeoutRetry: context.force,
|
|
timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true),
|
|
force: context.force)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.applyOpenAIDashboard(
|
|
dash,
|
|
targetEmail: effectiveEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
allowCodexUsageBackfill: context.allowCodexUsageBackfill)
|
|
} catch {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
let message = self.preferredOpenAIDashboardFailureMessage(
|
|
error: error,
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: latestCookieImportStatus)
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
}
|
|
}
|
|
|
|
private func retryOpenAIDashboardAfterNoData(
|
|
body: String,
|
|
context: OpenAIDashboardRefreshContext,
|
|
latestCookieImportStatus: inout String?,
|
|
logger: @escaping (String) -> Void) async
|
|
{
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback,
|
|
allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved)
|
|
var effectiveEmail = targetEmail
|
|
let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus()
|
|
if await self.abortOpenAIDashboardRetryAfterImportFailure(
|
|
importedEmail: imported,
|
|
targetEmail: targetEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
cookieImportStatus: latestCookieImportStatus,
|
|
refreshTaskToken: context.refreshTaskToken)
|
|
{
|
|
return
|
|
}
|
|
if let imported {
|
|
effectiveEmail = imported
|
|
}
|
|
do {
|
|
let dash = try await self.loadLatestOpenAIDashboard(
|
|
accountEmail: effectiveEmail,
|
|
logger: logger,
|
|
allowNavigationTimeoutRetry: context.force,
|
|
timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true),
|
|
force: context.force)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.applyOpenAIDashboard(
|
|
dash,
|
|
targetEmail: effectiveEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
allowCodexUsageBackfill: context.allowCodexUsageBackfill)
|
|
} catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
let finalBody = retryBody.isEmpty ? body : retryBody
|
|
let message = self.openAIDashboardFriendlyError(
|
|
body: finalBody,
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: latestCookieImportStatus)
|
|
?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
} catch {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
let message = self.preferredOpenAIDashboardFailureMessage(
|
|
error: error,
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: latestCookieImportStatus)
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
}
|
|
}
|
|
|
|
private func retryOpenAIDashboardAfterLoginRequired(
|
|
context: OpenAIDashboardRefreshContext,
|
|
latestCookieImportStatus: inout String?,
|
|
logger: @escaping (String) -> Void) async
|
|
{
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback,
|
|
allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved)
|
|
var effectiveEmail = targetEmail
|
|
let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus()
|
|
if await self.abortOpenAIDashboardRetryAfterImportFailure(
|
|
importedEmail: imported,
|
|
targetEmail: targetEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
cookieImportStatus: latestCookieImportStatus,
|
|
refreshTaskToken: context.refreshTaskToken)
|
|
{
|
|
return
|
|
}
|
|
if let imported {
|
|
effectiveEmail = imported
|
|
}
|
|
do {
|
|
let dash = try await self.loadLatestOpenAIDashboard(
|
|
accountEmail: effectiveEmail,
|
|
logger: logger,
|
|
allowNavigationTimeoutRetry: context.force,
|
|
timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true),
|
|
force: context.force)
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.applyOpenAIDashboard(
|
|
dash,
|
|
targetEmail: effectiveEmail,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
allowCodexUsageBackfill: context.allowCodexUsageBackfill)
|
|
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
await self.applyOpenAIDashboardLoginRequiredFailure(
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
} catch {
|
|
guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return }
|
|
let message = self.preferredOpenAIDashboardFailureMessage(
|
|
error: error,
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: latestCookieImportStatus)
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: context.expectedGuard,
|
|
refreshTaskToken: context.refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
}
|
|
}
|
|
|
|
// MARK: - OpenAI web account switching
|
|
|
|
/// Detect Codex account-source changes and clear stale OpenAI web state so the UI can't show the wrong user.
|
|
/// This does not delete other isolated WebKit cookie stores (we keep multiple accounts around).
|
|
func handleOpenAIWebTargetEmailChangeIfNeeded(
|
|
targetEmail: String?,
|
|
targetScope: CookieHeaderCache.Scope? = nil)
|
|
{
|
|
let normalized = targetEmail?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
|
|
guard let normalized, !normalized.isEmpty else { return }
|
|
|
|
let isolationKey = Self.openAIWebTargetIsolationKey(email: normalized, scope: targetScope)
|
|
let previousIsolationKey = self.lastOpenAIDashboardTargetIsolationKey
|
|
self.lastOpenAIDashboardTargetEmail = normalized
|
|
self.lastOpenAIDashboardTargetIsolationKey = isolationKey
|
|
|
|
if let previousIsolationKey,
|
|
previousIsolationKey != isolationKey
|
|
{
|
|
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
self.logOpenAIWeb(
|
|
"[\(stamp)] Codex account source changed; clearing OpenAI web snapshot")
|
|
self.openAIWebAccountDidChange = true
|
|
self.openAIDashboard = nil
|
|
self.openAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardSnapshot = nil
|
|
self.lastOpenAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardError = nil
|
|
self.lastOpenAIDashboardAttemptAt = nil
|
|
self.lastOpenAIDashboardPageScrapeAt = nil
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.openAIDashboardCookieImportStatus = L("Codex account changed; importing browser cookies…")
|
|
self.lastOpenAIDashboardCookieImportAttemptAt = nil
|
|
self.lastOpenAIDashboardCookieImportEmail = nil
|
|
}
|
|
}
|
|
|
|
nonisolated static func openAIWebTargetIsolationKey(
|
|
email: String,
|
|
scope: CookieHeaderCache.Scope?) -> String
|
|
{
|
|
"\(email.lowercased())|\(scope?.isolationIdentifier ?? "live")"
|
|
}
|
|
|
|
func importOpenAIDashboardBrowserCookiesNow() async {
|
|
self.resetOpenAIWebDebugLog(context: "manual import")
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: true,
|
|
allowLastKnownLiveFallback: false)
|
|
_ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
|
|
let expectedGuard = self.freshCodexOpenAIWebRefreshGuard()
|
|
await self.refreshOpenAIDashboardIfNeeded(
|
|
force: true,
|
|
expectedGuard: expectedGuard,
|
|
bypassCoalescing: true)
|
|
}
|
|
|
|
func currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: Bool,
|
|
allowLastKnownLiveFallback: Bool) -> String?
|
|
{
|
|
switch self.settings.codexResolvedActiveSource {
|
|
case .liveSystem:
|
|
let liveSystem = self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let liveSystem, !liveSystem.isEmpty {
|
|
self.lastKnownLiveSystemCodexEmail = liveSystem
|
|
return liveSystem
|
|
}
|
|
|
|
if allowCurrentSnapshotFallback,
|
|
// Provider-specific by design: OpenAI web attachment falls back to the current Codex owner email.
|
|
let snapshotEmail = self.snapshots[.codex]?.accountEmail(for: .codex)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!snapshotEmail.isEmpty
|
|
{
|
|
self.lastKnownLiveSystemCodexEmail = snapshotEmail
|
|
return snapshotEmail
|
|
}
|
|
|
|
if allowLastKnownLiveFallback {
|
|
let lastKnown = self.lastKnownLiveSystemCodexEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let lastKnown, !lastKnown.isEmpty {
|
|
return lastKnown
|
|
}
|
|
}
|
|
return nil
|
|
case .managedAccount:
|
|
return self.codexAccountEmailForOpenAIDashboard()
|
|
case let .profileHome(path):
|
|
return self.currentProfileCodexRuntimeEmail(path: path)
|
|
}
|
|
}
|
|
|
|
private func openAIDashboardRefreshKey(
|
|
targetEmail: String?,
|
|
expectedGuard: CodexAccountScopedRefreshGuard?) -> String
|
|
{
|
|
let source = String(describing: expectedGuard?.source ?? self.settings.codexResolvedActiveSource)
|
|
let identityKey = Self.codexIdentityGuardKey(expectedGuard?.identity ?? .unresolved) ?? "unresolved"
|
|
let accountKey = Self.normalizeCodexAccountScopedKey(targetEmail) ?? "unknown"
|
|
let authFingerprint = CodexAuthFingerprint.normalize(expectedGuard?.authFingerprint) ?? "nil"
|
|
return "\(source)|\(identityKey)|\(accountKey)|auth:\(authFingerprint)"
|
|
}
|
|
|
|
private func actionableOpenAIDashboardImportFailure(targetEmail: String?) -> String? {
|
|
self.actionableOpenAIDashboardImportFailure(
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: self.openAIDashboardCookieImportStatus)
|
|
}
|
|
|
|
private func actionableOpenAIDashboardImportFailure(
|
|
targetEmail: String?,
|
|
cookieImportStatus: String?) -> String?
|
|
{
|
|
let status = cookieImportStatus?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let status, !status.isEmpty else { return nil }
|
|
|
|
if status.localizedCaseInsensitiveContains("openai cookies are for") {
|
|
return "\(status) Switch chatgpt.com account, then refresh OpenAI cookies."
|
|
}
|
|
if status.localizedCaseInsensitiveContains("no signed-in openai web session found")
|
|
|| status.localizedCaseInsensitiveContains("no matching openai web session found")
|
|
{
|
|
let targetLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let accountLabel = (targetLabel?.isEmpty == false) ? targetLabel! : "your OpenAI account"
|
|
return "\(status) Sign in to chatgpt.com as \(accountLabel), then refresh OpenAI cookies."
|
|
}
|
|
if status.localizedCaseInsensitiveContains("openai cookie import failed")
|
|
|| status.localizedCaseInsensitiveContains("browser cookie import failed")
|
|
{
|
|
return status
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func preferredOpenAIDashboardFailureMessage(
|
|
error: Error,
|
|
targetEmail: String?,
|
|
cookieImportStatus: String?) -> String
|
|
{
|
|
if let actionable = self.actionableOpenAIDashboardImportFailure(
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: cookieImportStatus)
|
|
{
|
|
return actionable
|
|
}
|
|
return error.localizedDescription
|
|
}
|
|
|
|
private static func isOpenAIDashboardTimeout(_ error: Error) -> Bool {
|
|
let nsError = error as NSError
|
|
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
}
|
|
|
|
private func abortOpenAIDashboardRetryAfterImportFailure(
|
|
importedEmail: String?,
|
|
targetEmail: String?,
|
|
expectedGuard: CodexAccountScopedRefreshGuard?,
|
|
cookieImportStatus: String?,
|
|
refreshTaskToken: UUID) async -> Bool
|
|
{
|
|
guard importedEmail == nil,
|
|
let message = self.actionableOpenAIDashboardImportFailure(
|
|
targetEmail: targetEmail,
|
|
cookieImportStatus: cookieImportStatus)
|
|
else {
|
|
return false
|
|
}
|
|
await self.applyOpenAIDashboardFailure(
|
|
message: message,
|
|
expectedGuard: expectedGuard,
|
|
refreshTaskToken: refreshTaskToken,
|
|
routingTargetEmail: targetEmail)
|
|
return true
|
|
}
|
|
|
|
private func shouldApplyOpenAIDashboardRefreshTask(token: UUID?) -> Bool {
|
|
guard let token else { return true }
|
|
return self.openAIDashboardRefreshTaskToken == token
|
|
}
|
|
|
|
private func shouldContinueOpenAIDashboardRefresh(token: UUID?) -> Bool {
|
|
!Task.isCancelled && self.shouldApplyOpenAIDashboardRefreshTask(token: token)
|
|
}
|
|
|
|
func invalidateOpenAIDashboardRefreshTask() {
|
|
self.openAIDashboardBackgroundRefreshTask?.cancel()
|
|
self.openAIDashboardBackgroundRefreshTask = nil
|
|
self.openAIDashboardBackgroundRefreshTaskKey = nil
|
|
self.openAIDashboardRefreshTask?.cancel()
|
|
self.openAIDashboardRefreshTask = nil
|
|
self.openAIDashboardRefreshTaskKey = nil
|
|
self.openAIDashboardRefreshTaskToken = nil
|
|
}
|
|
|
|
private func currentOpenAIDashboardCookieImportStatus() -> String? {
|
|
self.openAIDashboardCookieImportStatus
|
|
}
|
|
|
|
private func loadLatestOpenAIDashboard(
|
|
accountEmail: String?,
|
|
logger: @escaping (String) -> Void,
|
|
allowNavigationTimeoutRetry: Bool,
|
|
timeout: TimeInterval,
|
|
force: Bool) async throws -> OpenAIDashboardSnapshot
|
|
{
|
|
if let override = self._test_openAIDashboardLoaderOverride {
|
|
return try await override(accountEmail, logger, allowNavigationTimeoutRetry, timeout)
|
|
}
|
|
let previousSnapshot = self.openAIDashboard ?? self.lastOpenAIDashboardSnapshot
|
|
let allowPageScrape = Self.shouldAllowOpenAIDashboardPageScrape(.init(
|
|
force: force,
|
|
userInitiated: ProviderInteractionContext.current == .userInitiated,
|
|
hasHistory: Self.dashboardHasPageHistory(previousSnapshot),
|
|
lastPageScrapeAt: self.lastOpenAIDashboardPageScrapeAt,
|
|
historyUpdatedAt: previousSnapshot?.updatedAt,
|
|
now: Date(),
|
|
refreshInterval: self.openAIWebRefreshIntervalSeconds()))
|
|
logger("dashboard page scrape=\(allowPageScrape ? "1" : "0")")
|
|
let snapshot = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
accountEmail: accountEmail,
|
|
cacheScope: self.codexCookieCacheScopeForOpenAIWeb(),
|
|
logger: logger,
|
|
debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout,
|
|
allowNavigationTimeoutRetry: allowNavigationTimeoutRetry,
|
|
timeout: timeout,
|
|
previousSnapshot: previousSnapshot,
|
|
allowPageScrape: allowPageScrape)
|
|
if allowPageScrape, Self.dashboardHasPageHistory(snapshot) {
|
|
self.lastOpenAIDashboardPageScrapeAt = Date()
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
private func failClosedForUnreadableManagedCodexStore() async -> String? {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.openAIDashboardCookieImportStatus = [
|
|
L("Managed Codex account data is unavailable."),
|
|
L("Fix the managed account store before importing OpenAI cookies."),
|
|
].joined(separator: " ")
|
|
return nil
|
|
}
|
|
|
|
private func failClosedRefreshForUnreadableManagedCodexStore() async {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.lastOpenAIDashboardError = [
|
|
L("Managed Codex account data is unavailable."),
|
|
L("Fix the managed account store before refreshing OpenAI web data."),
|
|
].joined(separator: " ")
|
|
}
|
|
|
|
private func failClosedForMissingManagedCodexTarget() async -> String? {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.openAIDashboardCookieImportStatus = [
|
|
L("The selected managed Codex account is unavailable."),
|
|
L("Pick another Codex account before importing OpenAI cookies."),
|
|
].joined(separator: " ")
|
|
return nil
|
|
}
|
|
|
|
private func failClosedRefreshForMissingManagedCodexTarget() async {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.lastOpenAIDashboardError = [
|
|
L("The selected managed Codex account is unavailable."),
|
|
L("Pick another Codex account before refreshing OpenAI web data."),
|
|
].joined(separator: " ")
|
|
}
|
|
|
|
private func failClosedForMissingProfileCodexTarget() async -> String? {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.openAIDashboardCookieImportStatus = [
|
|
L("The selected Codex profile has no verified account email."),
|
|
L("Refresh the profile before importing OpenAI cookies."),
|
|
].joined(separator: " ")
|
|
return nil
|
|
}
|
|
|
|
private func failClosedRefreshForMissingProfileCodexTarget() async {
|
|
self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false)
|
|
self.openAIDashboardRequiresLogin = true
|
|
self.lastOpenAIDashboardError = [
|
|
L("The selected Codex profile has no verified account email."),
|
|
L("Refresh the profile before refreshing OpenAI web data."),
|
|
].joined(separator: " ")
|
|
}
|
|
|
|
private func openAIWebCookieImportShouldFailClosed() async -> Bool {
|
|
if self.openAIWebManagedTargetStoreIsUnreadable() {
|
|
_ = await self.failClosedForUnreadableManagedCodexStore()
|
|
return true
|
|
}
|
|
if self.openAIWebManagedTargetIsMissing() {
|
|
_ = await self.failClosedForMissingManagedCodexTarget()
|
|
return true
|
|
}
|
|
if self.openAIWebProfileTargetEmailIsMissing() {
|
|
_ = await self.failClosedForMissingProfileCodexTarget()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func openAIDashboardCookieImportResult(
|
|
request: OpenAIDashboardCookieImportRequest,
|
|
logger: @escaping (String) -> Void) async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult
|
|
{
|
|
if let override = self._test_openAIDashboardCookieImportOverride {
|
|
return try await override(
|
|
request.normalizedTarget,
|
|
request.allowAnyAccount,
|
|
request.cookieSource,
|
|
request.cacheScope,
|
|
logger)
|
|
}
|
|
|
|
let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection)
|
|
switch request.cookieSource {
|
|
case .manual:
|
|
self.settings.ensureCodexCookieLoaded()
|
|
// Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are
|
|
// isolated per managed account, but a manual header is an explicit override owned by settings,
|
|
// so switching managed accounts does not currently swap it underneath the user.
|
|
let manualHeader = self.settings.codexCookieHeader
|
|
guard CookieHeaderNormalizer.normalize(manualHeader) != nil else {
|
|
throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid
|
|
}
|
|
return try await importer.importManualCookies(
|
|
cookieHeader: manualHeader,
|
|
intoAccountEmail: request.normalizedTarget,
|
|
allowAnyAccount: request.allowAnyAccount,
|
|
cacheScope: request.cacheScope,
|
|
logger: logger)
|
|
case .auto:
|
|
return try await importer.importBestCookies(
|
|
intoAccountEmail: request.normalizedTarget,
|
|
allowAnyAccount: request.allowAnyAccount,
|
|
preferCachedCookieHeader: request.preferCachedCookieHeader ?? !request.force,
|
|
cacheScope: request.cacheScope,
|
|
logger: logger)
|
|
case .off:
|
|
return OpenAIDashboardBrowserCookieImporter.ImportResult(
|
|
sourceLabel: "Off",
|
|
cookieCount: 0,
|
|
signedInEmail: request.normalizedTarget,
|
|
matchesCodexEmail: true)
|
|
}
|
|
}
|
|
|
|
func importOpenAIDashboardCookiesIfNeeded(
|
|
targetEmail: String?,
|
|
force: Bool,
|
|
preferCachedCookieHeader: Bool? = nil) async -> String?
|
|
{
|
|
guard !Task.isCancelled else { return nil }
|
|
if await self.openAIWebCookieImportShouldFailClosed() {
|
|
return nil
|
|
}
|
|
guard !Task.isCancelled else { return nil }
|
|
|
|
let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true
|
|
let cookieSource = self.settings.codexCookieSource
|
|
let cacheScope = self.codexCookieCacheScopeForOpenAIWeb()
|
|
|
|
let now = Date()
|
|
let lastEmail = self.lastOpenAIDashboardCookieImportEmail
|
|
let lastAttempt = self.lastOpenAIDashboardCookieImportAttemptAt ?? .distantPast
|
|
|
|
let shouldAttempt: Bool = if force {
|
|
true
|
|
} else {
|
|
if allowAnyAccount {
|
|
now.timeIntervalSince(lastAttempt) > 300
|
|
} else {
|
|
self.openAIDashboardRequiresLogin &&
|
|
(
|
|
lastEmail?.lowercased() != normalizedTarget?.lowercased() || now
|
|
.timeIntervalSince(lastAttempt) > 300)
|
|
}
|
|
}
|
|
|
|
guard shouldAttempt else { return normalizedTarget }
|
|
self.lastOpenAIDashboardCookieImportEmail = normalizedTarget
|
|
self.lastOpenAIDashboardCookieImportAttemptAt = now
|
|
|
|
let stamp = now.formatted(date: .abbreviated, time: .shortened)
|
|
let targetLabel = normalizedTarget ?? "unknown"
|
|
self.logOpenAIWeb("[\(stamp)] import start (target=\(targetLabel))")
|
|
|
|
do {
|
|
let log: (String) -> Void = { [weak self] message in
|
|
guard let self else { return }
|
|
self.logOpenAIWeb(message)
|
|
}
|
|
|
|
let request = OpenAIDashboardCookieImportRequest(
|
|
normalizedTarget: normalizedTarget,
|
|
allowAnyAccount: allowAnyAccount,
|
|
cookieSource: cookieSource,
|
|
cacheScope: cacheScope,
|
|
preferCachedCookieHeader: preferCachedCookieHeader,
|
|
force: force)
|
|
let result = try await self.openAIDashboardCookieImportResult(
|
|
request: request,
|
|
logger: log)
|
|
guard !Task.isCancelled else { return nil }
|
|
let effectiveEmail = result.signedInEmail?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.isEmpty == false
|
|
? result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
: normalizedTarget
|
|
self.lastOpenAIDashboardCookieImportEmail = effectiveEmail ?? normalizedTarget
|
|
await MainActor.run {
|
|
let signed = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let matchText = result.matchesCodexEmail ? "matches Codex" : "does not match Codex"
|
|
let sourceLabel = switch cookieSource {
|
|
case .manual:
|
|
"Manual cookie header"
|
|
case .auto:
|
|
"\(result.sourceLabel) cookies"
|
|
case .off:
|
|
"OpenAI cookies disabled"
|
|
}
|
|
if let signed, !signed.isEmpty {
|
|
self.openAIDashboardCookieImportStatus =
|
|
allowAnyAccount
|
|
? [
|
|
"Using \(sourceLabel) (\(result.cookieCount)).",
|
|
"Signed in as \(signed).",
|
|
].joined(separator: " ")
|
|
: [
|
|
"Using \(sourceLabel) (\(result.cookieCount)).",
|
|
"Signed in as \(signed) (\(matchText)).",
|
|
].joined(separator: " ")
|
|
} else {
|
|
self.openAIDashboardCookieImportStatus =
|
|
"Using \(sourceLabel) (\(result.cookieCount))."
|
|
}
|
|
}
|
|
return effectiveEmail
|
|
} catch let err as OpenAIDashboardBrowserCookieImporter.ImportError {
|
|
guard !Task.isCancelled else { return nil }
|
|
switch err {
|
|
case let .noMatchingAccount(found):
|
|
let foundText: String = if found.isEmpty {
|
|
"no signed-in session detected in \(self.codexBrowserCookieOrder.loginHint)"
|
|
} else {
|
|
found
|
|
.sorted { lhs, rhs in
|
|
if lhs.sourceLabel == rhs.sourceLabel {
|
|
return lhs.email < rhs.email
|
|
}
|
|
return lhs.sourceLabel < rhs.sourceLabel
|
|
}
|
|
.map { "\($0.sourceLabel): \($0.email)" }
|
|
.joined(separator: " • ")
|
|
}
|
|
self.logOpenAIWeb("[\(stamp)] import mismatch: \(foundText)")
|
|
await MainActor.run {
|
|
self.openAIDashboardCookieImportStatus = allowAnyAccount
|
|
? [
|
|
"No signed-in OpenAI web session found.",
|
|
"Found \(foundText).",
|
|
].joined(separator: " ")
|
|
: Self.conciseOpenAICookieMismatchStatus(
|
|
found: found.map(\.email),
|
|
targetEmail: normalizedTarget)
|
|
self.failClosedOpenAIDashboardSnapshot()
|
|
}
|
|
case .noCookiesFound,
|
|
.browserAccessDenied,
|
|
.browserCookieLoadTimedOut,
|
|
.dashboardStillRequiresLogin,
|
|
.manualCookieHeaderInvalid:
|
|
self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)")
|
|
await MainActor.run {
|
|
self.openAIDashboardCookieImportStatus =
|
|
"OpenAI cookie import failed: \(err.localizedDescription)"
|
|
self.openAIDashboardRequiresLogin = true
|
|
}
|
|
}
|
|
} catch {
|
|
guard !Task.isCancelled else { return nil }
|
|
self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)")
|
|
await MainActor.run {
|
|
self.openAIDashboardCookieImportStatus =
|
|
"Browser cookie import failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func resetOpenAIWebDebugLog(context: String) {
|
|
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
self.openAIWebDebugLines.removeAll(keepingCapacity: true)
|
|
self.openAIDashboardCookieImportDebugLog = nil
|
|
self.logOpenAIWeb("[\(stamp)] OpenAI web \(context) start")
|
|
}
|
|
|
|
private func logOpenAIWeb(_ message: String) {
|
|
let safeMessage = LogRedactor.redact(message)
|
|
self.openAIWebLogger.debug(safeMessage)
|
|
self.openAIWebDebugLines.append(safeMessage)
|
|
if self.openAIWebDebugLines.count > 240 {
|
|
self.openAIWebDebugLines.removeFirst(self.openAIWebDebugLines.count - 240)
|
|
}
|
|
self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n")
|
|
}
|
|
|
|
func resetOpenAIWebState() {
|
|
self.invalidateOpenAIDashboardRefreshTask()
|
|
OpenAIDashboardFetcher.evictAllCachedWebViews()
|
|
self.openAIDashboard = nil
|
|
self.openAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardError = nil
|
|
self.lastOpenAIDashboardSnapshot = nil
|
|
self.lastOpenAIDashboardAttachmentAuthorized = false
|
|
self.lastOpenAIDashboardTargetEmail = nil
|
|
self.lastOpenAIDashboardTargetIsolationKey = nil
|
|
self.lastOpenAIDashboardAttemptAt = nil
|
|
self.lastOpenAIDashboardPageScrapeAt = nil
|
|
self.openAIDashboardRequiresLogin = false
|
|
self.openAIDashboardCookieImportStatus = nil
|
|
self.openAIDashboardCookieImportDebugLog = nil
|
|
self.lastOpenAIDashboardCookieImportAttemptAt = nil
|
|
self.lastOpenAIDashboardCookieImportEmail = nil
|
|
self.lastKnownLiveSystemCodexEmail = nil
|
|
}
|
|
|
|
/// Routing-only optimization: this detects whether the fetched browser session appears to be for a
|
|
/// different account than the route target, so we can retry after cookie import. Ownership proof
|
|
/// happens exclusively through CodexDashboardAuthority.
|
|
private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool {
|
|
guard let expected, !expected.isEmpty else { return false }
|
|
guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false }
|
|
return raw.lowercased() != expected.lowercased()
|
|
}
|
|
|
|
private func openAIWebManagedTargetStoreIsUnreadable() -> Bool {
|
|
guard case .managedAccount = self.settings.codexResolvedActiveSource else {
|
|
return false
|
|
}
|
|
return self.settings.codexSettingsSnapshot(tokenOverride: nil).managedAccountStoreUnreadable
|
|
}
|
|
|
|
private func openAIWebManagedTargetIsMissing() -> Bool {
|
|
guard case .managedAccount = self.settings.codexResolvedActiveSource else {
|
|
return false
|
|
}
|
|
return self.selectedManagedCodexAccountForOpenAIWeb() == nil
|
|
}
|
|
|
|
private func openAIWebProfileTargetEmailIsMissing() -> Bool {
|
|
guard case let .profileHome(path) = self.settings.codexResolvedActiveSource else {
|
|
return false
|
|
}
|
|
return self.currentProfileCodexRuntimeEmail(path: path) == nil
|
|
}
|
|
|
|
private func selectedManagedCodexAccountForOpenAIWeb() -> ManagedCodexAccount? {
|
|
guard case let .managedAccount(id) = self.settings.codexResolvedActiveSource else {
|
|
return nil
|
|
}
|
|
|
|
let snapshot = self.settings.codexAccountReconciliationSnapshot
|
|
return snapshot.storedAccounts.first { $0.id == id }
|
|
}
|
|
|
|
func codexAccountEmailForOpenAIDashboard(allowLastKnownLiveFallback: Bool = true) -> String? {
|
|
switch self.settings.codexResolvedActiveSource {
|
|
case .liveSystem:
|
|
let liveSystem = self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let liveSystem, !liveSystem.isEmpty {
|
|
self.lastKnownLiveSystemCodexEmail = liveSystem
|
|
return liveSystem
|
|
}
|
|
|
|
guard allowLastKnownLiveFallback else { return nil }
|
|
let lastKnown = self.lastKnownLiveSystemCodexEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let lastKnown, !lastKnown.isEmpty {
|
|
return lastKnown
|
|
}
|
|
return nil
|
|
case .managedAccount:
|
|
if self.openAIWebManagedTargetStoreIsUnreadable() {
|
|
return nil
|
|
}
|
|
|
|
let managed = self.currentManagedCodexRuntimeEmail()?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let managed, !managed.isEmpty {
|
|
return managed
|
|
}
|
|
return nil
|
|
case let .profileHome(path):
|
|
let profile = self.currentProfileCodexRuntimeEmail(path: path)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let profile, !profile.isEmpty {
|
|
return profile
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func codexCookieCacheScopeForOpenAIWeb() -> CookieHeaderCache.Scope? {
|
|
switch self.settings.codexResolvedActiveSource {
|
|
case .liveSystem:
|
|
nil
|
|
case let .managedAccount(id):
|
|
self.openAIWebManagedTargetStoreIsUnreadable() ? .managedStoreUnreadable : .managedAccount(id)
|
|
case let .profileHome(path):
|
|
.profileHome(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - OpenAI web error messaging
|
|
|
|
extension UsageStore {
|
|
nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool {
|
|
guard context.accessEnabled else { return false }
|
|
guard context.force || context.refreshPhase != .startup else { return false }
|
|
return context.force || !context.batterySaverEnabled
|
|
}
|
|
|
|
nonisolated static func forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: Bool) -> Bool {
|
|
!batterySaverEnabled
|
|
}
|
|
|
|
nonisolated static func shouldSkipOpenAIWebRefresh(_ context: OpenAIWebRefreshGateContext) -> Bool {
|
|
if context.force || context.accountDidChange {
|
|
return false
|
|
}
|
|
if let lastAttemptAt = context.lastAttemptAt,
|
|
context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval
|
|
{
|
|
return true
|
|
}
|
|
if context.lastError == nil,
|
|
let lastSnapshotAt = context.lastSnapshotAt,
|
|
context.now.timeIntervalSince(lastSnapshotAt) < context.refreshInterval
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
struct OpenAIDashboardPageScrapeGate {
|
|
let force: Bool
|
|
let userInitiated: Bool
|
|
let hasHistory: Bool
|
|
let lastPageScrapeAt: Date?
|
|
let historyUpdatedAt: Date?
|
|
let now: Date
|
|
let refreshInterval: TimeInterval
|
|
}
|
|
|
|
nonisolated static func shouldAllowOpenAIDashboardPageScrape(_ gate: OpenAIDashboardPageScrapeGate) -> Bool {
|
|
if !gate.hasHistory {
|
|
return true
|
|
}
|
|
if gate.force, gate.userInitiated {
|
|
return true
|
|
}
|
|
guard let lastPageScrapeAt = gate.lastPageScrapeAt ?? gate.historyUpdatedAt else {
|
|
return false
|
|
}
|
|
return gate.now.timeIntervalSince(lastPageScrapeAt) >= gate.refreshInterval
|
|
}
|
|
|
|
nonisolated static func dashboardHasPageHistory(_ snapshot: OpenAIDashboardSnapshot?) -> Bool {
|
|
guard let snapshot else { return false }
|
|
return !snapshot.dailyBreakdown.isEmpty || !snapshot.usageBreakdown.isEmpty
|
|
}
|
|
|
|
nonisolated static func shouldSkipOpenAIWebEmptyHistoryRetry(_ context: OpenAIWebRefreshGateContext) -> Bool {
|
|
if context.force || context.accountDidChange {
|
|
return false
|
|
}
|
|
guard let lastAttemptAt = context.lastAttemptAt,
|
|
context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval
|
|
else { return false }
|
|
guard let lastSnapshotAt = context.lastSnapshotAt else { return true }
|
|
return lastAttemptAt >= lastSnapshotAt
|
|
}
|
|
|
|
func syncOpenAIWebState() {
|
|
// Provider-specific by design: the OpenAI WKWebView lifecycle is enabled only for Codex consumer access.
|
|
guard self.isEnabled(.codex),
|
|
self.settings.openAIWebAccessEnabled,
|
|
self.settings.codexCookieSource.isEnabled
|
|
else {
|
|
self.resetOpenAIWebState()
|
|
return
|
|
}
|
|
|
|
let targetEmail = self.currentCodexOpenAIWebTargetEmail(
|
|
allowCurrentSnapshotFallback: true,
|
|
allowLastKnownLiveFallback: true)
|
|
self.handleOpenAIWebTargetEmailChangeIfNeeded(
|
|
targetEmail: targetEmail,
|
|
targetScope: self.codexCookieCacheScopeForOpenAIWeb())
|
|
}
|
|
|
|
func openAIDashboardFriendlyError(
|
|
body: String,
|
|
targetEmail: String?,
|
|
cookieImportStatus: String?) -> String?
|
|
{
|
|
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let status = cookieImportStatus?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
return [
|
|
"OpenAI web dashboard returned an empty page.",
|
|
"Sign in to chatgpt.com and update OpenAI cookies in Providers → Codex.",
|
|
].joined(separator: " ")
|
|
}
|
|
|
|
let lower = trimmed.lowercased()
|
|
let looksLikePublicLanding = lower.contains("skip to content")
|
|
&& (lower.contains("about") || lower.contains("openai") || lower.contains("chatgpt"))
|
|
let looksLoggedOut = lower.contains("sign in")
|
|
|| lower.contains("log in")
|
|
|| lower.contains("create account")
|
|
|| lower.contains("continue with google")
|
|
|| lower.contains("continue with apple")
|
|
|| lower.contains("continue with microsoft")
|
|
|
|
guard looksLikePublicLanding || looksLoggedOut else { return nil }
|
|
let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let targetLabel = (emailLabel?.isEmpty == false) ? emailLabel! : "your OpenAI account"
|
|
if let status, !status.isEmpty {
|
|
if status.contains("cookies do not match Codex account")
|
|
|| status.localizedCaseInsensitiveContains("openai cookies are for")
|
|
|| status.localizedCaseInsensitiveContains("cookie import failed")
|
|
{
|
|
return "\(status) Switch chatgpt.com account, then refresh OpenAI cookies."
|
|
}
|
|
}
|
|
return [
|
|
"OpenAI web dashboard returned a public page (not signed in).",
|
|
"Sign in to chatgpt.com as \(targetLabel), then update OpenAI cookies in Providers → Codex.",
|
|
].joined(separator: " ")
|
|
}
|
|
|
|
private static func conciseOpenAICookieMismatchStatus(
|
|
found: [String],
|
|
targetEmail: String?)
|
|
-> String
|
|
{
|
|
let normalizedFound = Array(Set(
|
|
found
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
|
.filter { !$0.isEmpty }))
|
|
.sorted()
|
|
|
|
let foundLabel: String = switch normalizedFound.count {
|
|
case 0:
|
|
""
|
|
case 1:
|
|
normalizedFound[0]
|
|
case 2:
|
|
"\(normalizedFound[0]) or \(normalizedFound[1])"
|
|
default:
|
|
"\(normalizedFound[0]) or \(normalizedFound.count - 1) other accounts"
|
|
}
|
|
|
|
let targetLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if normalizedFound.isEmpty {
|
|
guard let targetLabel, !targetLabel.isEmpty else {
|
|
return L("No matching OpenAI web session found.")
|
|
}
|
|
return L("No matching OpenAI web session found for %@.", targetLabel)
|
|
}
|
|
guard let targetLabel, !targetLabel.isEmpty else {
|
|
return L("OpenAI cookies are for %@.", foundLabel)
|
|
}
|
|
return L("OpenAI cookies are for %1$@, not %2$@.", foundLabel, targetLabel)
|
|
}
|
|
}
|