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) { 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.openAIWebBatterySaverEnabled) || needsMenuHistoryRefresh self.openAIWebLogger.info( "OpenAI web stale refresh gate", metadata: [ "reason": reason, "force": forceRefresh ? "1" : "0", "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "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 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, 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)) 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)) 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) } } 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)) 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)) 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)) 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.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, 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) async throws -> OpenAIDashboardSnapshot { if let override = self._test_openAIDashboardLoaderOverride { return try await override(accountEmail, logger, allowNavigationTimeoutRetry, timeout) } return try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: accountEmail, cacheScope: self.codexCookieCacheScopeForOpenAIWeb(), logger: logger, debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout, allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } 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.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 } 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() { 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) } }