1054 lines
42 KiB
Swift
1054 lines
42 KiB
Swift
#if os(macOS)
|
|
import Foundation
|
|
import SweetCookieKit
|
|
import WebKit
|
|
|
|
@MainActor
|
|
// swiftlint:disable:next type_body_length
|
|
public struct OpenAIDashboardBrowserCookieImporter {
|
|
public struct FoundAccount: Sendable, Hashable {
|
|
public let sourceLabel: String
|
|
public let email: String
|
|
|
|
public init(sourceLabel: String, email: String) {
|
|
self.sourceLabel = sourceLabel
|
|
self.email = email
|
|
}
|
|
}
|
|
|
|
public enum ImportError: LocalizedError {
|
|
case noCookiesFound
|
|
case browserAccessDenied(details: String)
|
|
case browserCookieLoadTimedOut(details: String)
|
|
case dashboardStillRequiresLogin
|
|
case noMatchingAccount(found: [FoundAccount])
|
|
case manualCookieHeaderInvalid
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noCookiesFound:
|
|
return "No browser cookies found."
|
|
case let .browserAccessDenied(details):
|
|
return "Browser cookie access denied. \(details)"
|
|
case let .browserCookieLoadTimedOut(details):
|
|
return "Browser cookie loading timed out. \(details)"
|
|
case .dashboardStillRequiresLogin:
|
|
return "Browser cookies imported, but dashboard still requires login."
|
|
case let .noMatchingAccount(found):
|
|
if found.isEmpty { return "No matching OpenAI web session found in browsers." }
|
|
let display = 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: ", ")
|
|
return "OpenAI web session does not match Codex account. Found: \(display)."
|
|
case .manualCookieHeaderInvalid:
|
|
return "Manual cookie header is missing a valid OpenAI session cookie."
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ImportResult: Sendable {
|
|
public let sourceLabel: String
|
|
public let cookieCount: Int
|
|
public let signedInEmail: String?
|
|
public let matchesCodexEmail: Bool
|
|
|
|
public init(
|
|
sourceLabel: String,
|
|
cookieCount: Int,
|
|
signedInEmail: String?,
|
|
matchesCodexEmail: Bool)
|
|
{
|
|
self.sourceLabel = sourceLabel
|
|
self.cookieCount = cookieCount
|
|
self.signedInEmail = signedInEmail
|
|
self.matchesCodexEmail = matchesCodexEmail
|
|
}
|
|
}
|
|
|
|
public init(browserDetection: BrowserDetection) {
|
|
self.browserDetection = browserDetection
|
|
}
|
|
|
|
private let browserDetection: BrowserDetection
|
|
|
|
struct PersistentValidationTimeout: Error {}
|
|
|
|
nonisolated static func shouldTrustVerifiedSession(afterPersistFailure error: Error) -> Bool {
|
|
error is PersistentValidationTimeout
|
|
}
|
|
|
|
nonisolated static func persistentValidationFailure(_ error: Error) -> Error {
|
|
let nsError = error as NSError
|
|
guard nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorTimedOut else { return error }
|
|
return PersistentValidationTimeout()
|
|
}
|
|
|
|
private struct ImportDiagnostics {
|
|
var mismatches: [FoundAccount] = []
|
|
var foundAnyCookies: Bool = false
|
|
var foundUnknownEmail: Bool = false
|
|
var accessDeniedHints: [String] = []
|
|
}
|
|
|
|
private struct ImportContext {
|
|
let targetEmail: String?
|
|
let allowAnyAccount: Bool
|
|
let cacheScope: CookieHeaderCache.Scope?
|
|
let deadline: Date?
|
|
}
|
|
|
|
private static let cookieDomains = ["chatgpt.com", "openai.com"]
|
|
private nonisolated static let cookieClient = BrowserCookieClient()
|
|
private static let cookieImportOrder: BrowserCookieImportOrder =
|
|
ProviderDefaults.metadata[.codex]?.browserCookieOrder ?? Browser.defaultImportOrder
|
|
|
|
private enum CandidateEvaluation {
|
|
case match(candidate: Candidate, signedInEmail: String)
|
|
case mismatch(candidate: Candidate, signedInEmail: String)
|
|
case loggedIn(candidate: Candidate, signedInEmail: String)
|
|
case unknown(candidate: Candidate)
|
|
case loginRequired(candidate: Candidate)
|
|
}
|
|
|
|
public func importBestCookies(
|
|
intoAccountEmail targetEmail: String?,
|
|
allowAnyAccount: Bool = false,
|
|
preferCachedCookieHeader: Bool = true,
|
|
cacheScope: CookieHeaderCache.Scope? = nil,
|
|
deadline: Date? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> ImportResult
|
|
{
|
|
let log: (String) -> Void = { message in
|
|
logger?("[web] \(message)")
|
|
}
|
|
|
|
let targetEmail = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedTarget = targetEmail?.isEmpty == false ? targetEmail : nil
|
|
let context = ImportContext(
|
|
targetEmail: normalizedTarget,
|
|
allowAnyAccount: allowAnyAccount,
|
|
cacheScope: cacheScope,
|
|
deadline: deadline)
|
|
|
|
if normalizedTarget != nil {
|
|
log("Codex email known; matching required.")
|
|
} else {
|
|
guard context.allowAnyAccount else {
|
|
throw ImportError.noCookiesFound
|
|
}
|
|
log("Codex email unknown; importing any signed-in session.")
|
|
}
|
|
|
|
var diagnostics = ImportDiagnostics()
|
|
|
|
if preferCachedCookieHeader {
|
|
let cached = try await Self.runBoundedCookieCacheOperation(deadline: deadline) {
|
|
CookieHeaderCache.loadSerialized(provider: .codex, scope: cacheScope)
|
|
}
|
|
if let cached,
|
|
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
log("Using cached cookie header from \(cached.sourceLabel)")
|
|
do {
|
|
return try await self.importManualCookies(
|
|
cookieHeader: cached.cookieHeader,
|
|
intoAccountEmail: context.targetEmail,
|
|
allowAnyAccount: context.allowAnyAccount,
|
|
cacheScope: cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
} catch let error as ImportError {
|
|
switch error {
|
|
case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin:
|
|
do {
|
|
_ = try await Self.runBoundedCookieCacheOperation(deadline: deadline) {
|
|
CookieHeaderCache.clear(provider: .codex, scope: cacheScope)
|
|
}
|
|
} catch {
|
|
log("Stale cookie cache cleanup did not finish before the web deadline.")
|
|
}
|
|
default:
|
|
throw error
|
|
}
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
} else {
|
|
log("Skipping cached cookie header; forcing fresh browser import")
|
|
}
|
|
|
|
for browser in Self.cookieImportOrder {
|
|
guard let issue = self.browserDetection.cookieSourceProfileAccessIssue(browser) else { continue }
|
|
let hint = Self.browserProfileAccessHint(for: browser, issue: issue)
|
|
diagnostics.accessDeniedHints.append(hint)
|
|
log(hint)
|
|
}
|
|
|
|
// Filter to cookie-eligible browsers to avoid unnecessary keychain prompts
|
|
let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: self.browserDetection)
|
|
for browserSource in installedBrowsers {
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
if let match = try await self.trySource(
|
|
browserSource,
|
|
context: context,
|
|
deadline: deadline,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
{
|
|
return match
|
|
}
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
|
|
if !diagnostics.mismatches.isEmpty {
|
|
let found = Array(Set(diagnostics.mismatches)).sorted { lhs, rhs in
|
|
if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email }
|
|
return lhs.sourceLabel < rhs.sourceLabel
|
|
}
|
|
log("No matching browser session found. Candidate count: \(found.count)")
|
|
throw ImportError.noMatchingAccount(found: found)
|
|
}
|
|
|
|
if diagnostics.foundUnknownEmail || diagnostics.foundAnyCookies {
|
|
log("No matching browser session found (email unknown).")
|
|
throw ImportError.noMatchingAccount(found: [])
|
|
}
|
|
|
|
if !diagnostics.accessDeniedHints.isEmpty {
|
|
let details = Array(Set(diagnostics.accessDeniedHints)).sorted().joined(separator: " ")
|
|
log("Cookie access denied: \(details)")
|
|
throw ImportError.browserAccessDenied(details: details)
|
|
}
|
|
|
|
throw ImportError.noCookiesFound
|
|
}
|
|
|
|
nonisolated static func browserProfileAccessHint(
|
|
for browser: Browser,
|
|
issue: BrowserProfileAccessIssue,
|
|
processName: String = ProcessInfo.processInfo.processName,
|
|
executablePath: String? = Bundle.main.executablePath) -> String
|
|
{
|
|
let failure = switch issue {
|
|
case .accessDenied: "macOS denied"
|
|
case .unreadable: "macOS could not read"
|
|
}
|
|
let executable = executablePath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let target: String = if let executable, !executable.isEmpty {
|
|
Self.fullDiskAccessTarget(processName: processName, executablePath: executable)
|
|
} else {
|
|
processName
|
|
}
|
|
return "\(failure) \(browser.displayName) profile access for \(target). " +
|
|
"Grant that exact component Full Disk Access, then quit and reopen it."
|
|
}
|
|
|
|
private nonisolated static func fullDiskAccessTarget(processName: String, executablePath: String) -> String {
|
|
guard processName != "CodexBarCLI",
|
|
let appSuffix = executablePath.range(of: ".app/")
|
|
else {
|
|
return "\(processName) (\(executablePath))"
|
|
}
|
|
let appPath = executablePath[..<appSuffix.upperBound].dropLast()
|
|
return "CodexBar.app (\(appPath))"
|
|
}
|
|
|
|
nonisolated static func browserCookieLoadTimeoutError(
|
|
for browser: Browser,
|
|
processName: String = ProcessInfo.processInfo.processName,
|
|
executablePath: String? = Bundle.main.executablePath) -> ImportError
|
|
{
|
|
let executable = executablePath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let target: String = if let executable, !executable.isEmpty {
|
|
Self.fullDiskAccessTarget(processName: processName, executablePath: executable)
|
|
} else {
|
|
processName
|
|
}
|
|
let keychainHint = browser.usesKeychainForCookieDecryption
|
|
? " If a macOS Keychain prompt is waiting, approve it."
|
|
: ""
|
|
let details = "\(browser.displayName) did not finish before the web timeout for \(target)." + keychainHint +
|
|
" If the timeout repeats, grant that exact component Full Disk Access, quit and reopen it, then retry."
|
|
return .browserCookieLoadTimedOut(details: details)
|
|
}
|
|
|
|
public func importManualCookies(
|
|
cookieHeader: String,
|
|
intoAccountEmail targetEmail: String?,
|
|
allowAnyAccount: Bool = false,
|
|
cacheScope: CookieHeaderCache.Scope? = nil,
|
|
deadline: Date? = nil,
|
|
logger: ((String) -> Void)? = nil) async throws -> ImportResult
|
|
{
|
|
let log: (String) -> Void = { message in
|
|
logger?("[web] \(message)")
|
|
}
|
|
let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let allowAnyAccount = allowAnyAccount || normalizedTarget == nil || normalizedTarget?.isEmpty == true
|
|
|
|
guard let normalized = CookieHeaderNormalizer.normalize(cookieHeader) else {
|
|
throw ImportError.manualCookieHeaderInvalid
|
|
}
|
|
let pairs = CookieHeaderNormalizer.pairs(from: normalized)
|
|
guard !pairs.isEmpty else { throw ImportError.manualCookieHeaderInvalid }
|
|
let cookies = self.cookies(from: pairs)
|
|
guard !cookies.isEmpty else { throw ImportError.manualCookieHeaderInvalid }
|
|
|
|
let candidate = Candidate(label: "Manual", cookies: cookies)
|
|
switch try await self.evaluateCandidate(
|
|
candidate,
|
|
targetEmail: normalizedTarget,
|
|
allowAnyAccount: allowAnyAccount,
|
|
deadline: deadline,
|
|
log: log)
|
|
{
|
|
case let .match(_, signedInEmail):
|
|
return try await self.persistVerifiedCandidate(
|
|
candidate: candidate,
|
|
targetEmail: signedInEmail,
|
|
cacheScope: cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
case let .loggedIn(_, signedInEmail):
|
|
return try await self.persistVerifiedCandidate(
|
|
candidate: candidate,
|
|
targetEmail: signedInEmail,
|
|
cacheScope: cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
case let .mismatch(_, signedInEmail):
|
|
throw ImportError.noMatchingAccount(found: [FoundAccount(sourceLabel: "Manual", email: signedInEmail)])
|
|
case .unknown:
|
|
if allowAnyAccount {
|
|
return try await self.persistToDefaultStore(
|
|
candidate: candidate,
|
|
deadline: deadline,
|
|
logger: log)
|
|
}
|
|
throw ImportError.noMatchingAccount(found: [])
|
|
case .loginRequired:
|
|
throw ImportError.manualCookieHeaderInvalid
|
|
}
|
|
}
|
|
|
|
private func trySafari(
|
|
context: ImportContext,
|
|
deadline: Date?,
|
|
log: @escaping (String) -> Void,
|
|
diagnostics: inout ImportDiagnostics) async throws -> ImportResult?
|
|
{
|
|
// Safari first: avoids touching Keychain ("Chrome Safe Storage") when Safari already matches.
|
|
do {
|
|
let query = BrowserCookieQuery(domains: Self.cookieDomains)
|
|
let sources: [BrowserCookieStoreRecords]
|
|
do {
|
|
sources = try await Self.runBoundedCookieLoad(deadline: deadline) {
|
|
try Self.cookieClient.codexBarRecords(matching: query, in: .safari)
|
|
}
|
|
} catch let error as URLError where error.code == .timedOut {
|
|
let timeoutError = Self.browserCookieLoadTimeoutError(for: .safari)
|
|
log(timeoutError.localizedDescription)
|
|
throw timeoutError
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
guard !sources.isEmpty else {
|
|
log("Safari contained 0 matching records.")
|
|
return nil
|
|
}
|
|
for source in sources {
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
|
guard !cookies.isEmpty else {
|
|
log("\(source.label) produced 0 HTTPCookies.")
|
|
continue
|
|
}
|
|
|
|
diagnostics.foundAnyCookies = true
|
|
log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))")
|
|
let candidate = Candidate(label: source.label, cookies: cookies)
|
|
if let match = try await self.applyCandidate(
|
|
candidate,
|
|
context: context,
|
|
deadline: deadline,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
{
|
|
return match
|
|
}
|
|
}
|
|
return nil
|
|
} catch let error as ImportError {
|
|
throw error
|
|
} catch let error as URLError where error.code == .timedOut {
|
|
throw error
|
|
} catch let error as BrowserCookieError {
|
|
BrowserCookieAccessGate.recordIfNeeded(error)
|
|
if let hint = error.accessDeniedHint {
|
|
diagnostics.accessDeniedHints.append(hint)
|
|
}
|
|
log("Safari cookie load failed: \(error.localizedDescription)")
|
|
return nil
|
|
} catch {
|
|
log("Safari cookie load failed: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Generic cookie loader for any non-Safari browser (Chrome, Edge, Firefox, Brave, Arc, etc.).
|
|
/// SweetCookieKit handles engine-specific decryption internally.
|
|
private func tryBrowser(
|
|
_ browser: Browser,
|
|
context: ImportContext,
|
|
deadline: Date?,
|
|
log: @escaping (String) -> Void,
|
|
diagnostics: inout ImportDiagnostics) async throws -> ImportResult?
|
|
{
|
|
do {
|
|
let query = BrowserCookieQuery(domains: Self.cookieDomains)
|
|
let sources: [BrowserCookieStoreRecords]
|
|
do {
|
|
sources = try await Self.runBoundedCookieLoad(deadline: deadline) {
|
|
try Self.cookieClient.codexBarRecords(matching: query, in: browser)
|
|
}
|
|
} catch let error as URLError where error.code == .timedOut {
|
|
let timeoutError = Self.browserCookieLoadTimeoutError(for: browser)
|
|
log(timeoutError.localizedDescription)
|
|
throw timeoutError
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
guard !sources.isEmpty else {
|
|
log("\(browser.displayName) contained 0 matching records.")
|
|
return nil
|
|
}
|
|
for source in sources {
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
|
guard !cookies.isEmpty else {
|
|
log("\(source.label) produced 0 HTTPCookies.")
|
|
continue
|
|
}
|
|
diagnostics.foundAnyCookies = true
|
|
log("Loaded \(cookies.count) cookies from \(source.label) (\(self.cookieSummary(cookies)))")
|
|
let candidate = Candidate(label: source.label, cookies: cookies)
|
|
if let match = try await self.applyCandidate(
|
|
candidate,
|
|
context: context,
|
|
deadline: deadline,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
{
|
|
return match
|
|
}
|
|
}
|
|
return nil
|
|
} catch let error as ImportError {
|
|
throw error
|
|
} catch let error as URLError where error.code == .timedOut {
|
|
throw error
|
|
} catch let error as BrowserCookieError {
|
|
BrowserCookieAccessGate.recordIfNeeded(error)
|
|
if let hint = error.accessDeniedHint {
|
|
diagnostics.accessDeniedHints.append(hint)
|
|
}
|
|
log("\(browser.displayName) cookie load failed: \(error.localizedDescription)")
|
|
return nil
|
|
} catch {
|
|
log("\(browser.displayName) cookie load failed: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func trySource(
|
|
_ source: Browser,
|
|
context: ImportContext,
|
|
deadline: Date?,
|
|
log: @escaping (String) -> Void,
|
|
diagnostics: inout ImportDiagnostics) async throws -> ImportResult?
|
|
{
|
|
switch source {
|
|
case .safari:
|
|
try await self.trySafari(
|
|
context: context,
|
|
deadline: deadline,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
default:
|
|
// All non-Safari browsers (Chrome, Edge, Firefox, Brave, Arc, etc.)
|
|
// share the same cookie loading path via SweetCookieKit.
|
|
try await self.tryBrowser(
|
|
source,
|
|
context: context,
|
|
deadline: deadline,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
}
|
|
}
|
|
|
|
private func applyCandidate(
|
|
_ candidate: Candidate,
|
|
context: ImportContext,
|
|
deadline: Date?,
|
|
log: @escaping (String) -> Void,
|
|
diagnostics: inout ImportDiagnostics) async throws -> ImportResult?
|
|
{
|
|
switch try await self.evaluateCandidate(
|
|
candidate,
|
|
targetEmail: context.targetEmail,
|
|
allowAnyAccount: context.allowAnyAccount,
|
|
deadline: deadline,
|
|
log: log)
|
|
{
|
|
case let .match(candidate, signedInEmail):
|
|
log("Selected \(candidate.label) (matches Codex: \(signedInEmail))")
|
|
guard let targetEmail = context.targetEmail else { return nil }
|
|
if let result = try? await self.persistVerifiedCandidate(
|
|
candidate: candidate,
|
|
targetEmail: targetEmail,
|
|
cacheScope: context.cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
{
|
|
await self.cacheCookies(
|
|
candidate: candidate,
|
|
scope: context.cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
return result
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
return nil
|
|
case let .mismatch(candidate, signedInEmail):
|
|
try await self.handleMismatch(
|
|
candidate: candidate,
|
|
signedInEmail: signedInEmail,
|
|
context: context,
|
|
log: log,
|
|
diagnostics: &diagnostics)
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
return nil
|
|
case let .loggedIn(candidate, signedInEmail):
|
|
log("Selected \(candidate.label) (signed in: \(signedInEmail))")
|
|
if let result = try? await self.persistVerifiedCandidate(
|
|
candidate: candidate,
|
|
targetEmail: signedInEmail,
|
|
cacheScope: context.cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
{
|
|
await self.cacheCookies(
|
|
candidate: candidate,
|
|
scope: context.cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
return result
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
return nil
|
|
case .unknown:
|
|
if context.allowAnyAccount {
|
|
log("Selected \(candidate.label) (signed in: unknown)")
|
|
if let result = try? await self.persistToDefaultStore(
|
|
candidate: candidate,
|
|
deadline: deadline,
|
|
logger: log)
|
|
{
|
|
await self.cacheCookies(
|
|
candidate: candidate,
|
|
scope: context.cacheScope,
|
|
deadline: deadline,
|
|
logger: log)
|
|
return result
|
|
}
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
return nil
|
|
}
|
|
diagnostics.foundUnknownEmail = true
|
|
return nil
|
|
case .loginRequired:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func evaluateCandidate(
|
|
_ candidate: Candidate,
|
|
targetEmail: String?,
|
|
allowAnyAccount: Bool,
|
|
deadline: Date?,
|
|
log: @escaping (String) -> Void) async throws -> CandidateEvaluation
|
|
{
|
|
log("Trying candidate \(candidate.label) (\(candidate.cookies.count) cookies)")
|
|
|
|
let apiEmail = try await self.fetchSignedInEmailFromAPI(
|
|
cookies: candidate.cookies,
|
|
deadline: deadline,
|
|
logger: log)
|
|
if let apiEmail {
|
|
log("Candidate \(candidate.label) API email: \(apiEmail)")
|
|
}
|
|
|
|
// Prefer the API email when available (fast; avoids WebKit hydration/timeout risks).
|
|
if let apiEmail, !apiEmail.isEmpty {
|
|
if let targetEmail {
|
|
if apiEmail.lowercased() == targetEmail.lowercased() {
|
|
return .match(candidate: candidate, signedInEmail: apiEmail)
|
|
}
|
|
return .mismatch(candidate: candidate, signedInEmail: apiEmail)
|
|
}
|
|
if allowAnyAccount { return .loggedIn(candidate: candidate, signedInEmail: apiEmail) }
|
|
}
|
|
|
|
if !self.hasSessionCookies(candidate.cookies) {
|
|
log("Candidate \(candidate.label) missing session cookies; skipping")
|
|
return .loginRequired(candidate: candidate)
|
|
}
|
|
|
|
let scratch = WKWebsiteDataStore.nonPersistent()
|
|
try await self.setCookies(candidate.cookies, into: scratch, deadline: deadline)
|
|
|
|
do {
|
|
let probe = try await OpenAIDashboardFetcher().probeUsagePage(
|
|
websiteDataStore: scratch,
|
|
logger: log,
|
|
timeout: Self.remainingTimeout(until: deadline, cappedAt: 25))
|
|
let signedInEmail = probe.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
log("Candidate \(candidate.label) DOM email: \(signedInEmail ?? "unknown")")
|
|
|
|
let resolvedEmail = signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let resolvedEmail, !resolvedEmail.isEmpty {
|
|
if let targetEmail {
|
|
if resolvedEmail.lowercased() == targetEmail.lowercased() {
|
|
return .match(candidate: candidate, signedInEmail: resolvedEmail)
|
|
}
|
|
return .mismatch(candidate: candidate, signedInEmail: resolvedEmail)
|
|
}
|
|
if allowAnyAccount { return .loggedIn(candidate: candidate, signedInEmail: resolvedEmail) }
|
|
}
|
|
|
|
return .unknown(candidate: candidate)
|
|
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
log("Candidate \(candidate.label) requires login.")
|
|
return .loginRequired(candidate: candidate)
|
|
} catch {
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
log("Candidate \(candidate.label) probe error: \(error.localizedDescription)")
|
|
return .unknown(candidate: candidate)
|
|
}
|
|
}
|
|
|
|
private func hasSessionCookies(_ cookies: [HTTPCookie]) -> Bool {
|
|
for cookie in cookies {
|
|
let name = cookie.name.lowercased()
|
|
if name.contains("session-token") || name.contains("authjs") || name.contains("next-auth") {
|
|
return true
|
|
}
|
|
if name == "_account" { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func handleMismatch(
|
|
candidate: Candidate,
|
|
signedInEmail: String,
|
|
context: ImportContext,
|
|
log: @escaping (String) -> Void,
|
|
diagnostics: inout ImportDiagnostics) async throws
|
|
{
|
|
log("Candidate \(candidate.label) mismatch (\(signedInEmail)); continuing browser search")
|
|
diagnostics.mismatches.append(FoundAccount(sourceLabel: candidate.label, email: signedInEmail))
|
|
// Mismatch still means we found a valid signed-in session. Keep it inside the active source scope;
|
|
// a profile import must never populate the live account or another profile's WebKit store.
|
|
do {
|
|
try await self.persistCookies(
|
|
candidate: candidate,
|
|
accountEmail: signedInEmail,
|
|
cacheScope: context.cacheScope,
|
|
deadline: context.deadline,
|
|
logger: log)
|
|
} catch {
|
|
log("Could not cache mismatched session: \(error.localizedDescription)")
|
|
_ = try Self.remainingTimeout(until: context.deadline)
|
|
}
|
|
}
|
|
|
|
func fetchSignedInEmailFromAPI(
|
|
cookies: [HTTPCookie],
|
|
deadline: Date?,
|
|
logger: (String) -> Void) async throws -> String?
|
|
{
|
|
let chatgptCookies = cookies.filter { $0.domain.lowercased().contains("chatgpt.com") }
|
|
guard !chatgptCookies.isEmpty else { return nil }
|
|
|
|
let cookieHeader = chatgptCookies
|
|
.map { "\($0.name)=\($0.value)" }
|
|
.joined(separator: "; ")
|
|
|
|
let endpoints = [
|
|
"https://chatgpt.com/backend-api/me",
|
|
"https://chatgpt.com/api/auth/session",
|
|
]
|
|
|
|
for urlString in endpoints {
|
|
let requestTimeout = try Self.remainingTimeout(until: deadline, cappedAt: 10)
|
|
guard let url = URL(string: urlString) else { continue }
|
|
let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(
|
|
url: url,
|
|
cookieHeader: cookieHeader,
|
|
timeout: requestTimeout)
|
|
|
|
do {
|
|
let (data, response) = try await CodexAuthenticatedHTTPTransport.current.data(for: request)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
logger("API \(url.host ?? "chatgpt.com") \(url.path) status=\(status)")
|
|
guard status >= 200, status < 300 else { continue }
|
|
if let email = Self.findFirstEmail(inJSONData: data) {
|
|
return email.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
} catch {
|
|
_ = try Self.remainingTimeout(until: deadline)
|
|
logger("API request failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func findFirstEmail(inJSONData data: Data) -> String? {
|
|
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
|
var queue: [Any] = [json]
|
|
var seen = 0
|
|
while !queue.isEmpty, seen < 2000 {
|
|
let cur = queue.removeFirst()
|
|
seen += 1
|
|
if let str = cur as? String, str.contains("@") {
|
|
return str
|
|
}
|
|
if let dict = cur as? [String: Any] {
|
|
for (k, v) in dict {
|
|
if k.lowercased() == "email", let s = v as? String, s.contains("@") { return s }
|
|
queue.append(v)
|
|
}
|
|
} else if let arr = cur as? [Any] {
|
|
queue.append(contentsOf: arr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func persistVerifiedCandidate(
|
|
candidate: Candidate,
|
|
targetEmail: String,
|
|
cacheScope: CookieHeaderCache.Scope?,
|
|
deadline: Date?,
|
|
logger: @escaping (String) -> Void) async throws -> ImportResult
|
|
{
|
|
do {
|
|
return try await self.persist(
|
|
candidate: candidate,
|
|
targetEmail: targetEmail,
|
|
cacheScope: cacheScope,
|
|
deadline: deadline,
|
|
logger: logger)
|
|
} catch {
|
|
guard Self.shouldTrustVerifiedSession(afterPersistFailure: error) else {
|
|
throw error
|
|
}
|
|
|
|
let signedInEmail = targetEmail.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
logger(
|
|
"Persistent validation timed out after session verification; " +
|
|
"keeping \(candidate.label) cookies for \(signedInEmail).")
|
|
return ImportResult(
|
|
sourceLabel: candidate.label,
|
|
cookieCount: candidate.cookies.count,
|
|
signedInEmail: signedInEmail,
|
|
matchesCodexEmail: signedInEmail.lowercased() == targetEmail.lowercased())
|
|
}
|
|
}
|
|
|
|
private func persist(
|
|
candidate: Candidate,
|
|
targetEmail: String,
|
|
cacheScope: CookieHeaderCache.Scope?,
|
|
deadline: Date?,
|
|
logger: @escaping (String) -> Void) async throws -> ImportResult
|
|
{
|
|
let persistent = OpenAIDashboardWebsiteDataStore.store(
|
|
forAccountEmail: targetEmail,
|
|
scope: cacheScope)
|
|
try await self.clearChatGPTCookies(in: persistent, deadline: deadline)
|
|
try await self.setCookies(candidate.cookies, into: persistent, deadline: deadline)
|
|
|
|
// Validate against the persistent store (login + email sync).
|
|
do {
|
|
let probe = try await OpenAIDashboardFetcher().probeUsagePage(
|
|
websiteDataStore: persistent,
|
|
logger: logger,
|
|
timeout: Self.remainingTimeout(until: deadline, cappedAt: 20),
|
|
preserveLoadedPageForReuse: true)
|
|
let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
let matches = signed?.lowercased() == targetEmail.lowercased()
|
|
logger("Persistent session signed in as: \(signed ?? "unknown")")
|
|
if signed != nil, matches == false {
|
|
let found = signed?.isEmpty == false
|
|
? [FoundAccount(sourceLabel: candidate.label, email: signed!)]
|
|
: []
|
|
throw ImportError.noMatchingAccount(found: found)
|
|
}
|
|
return ImportResult(
|
|
sourceLabel: candidate.label,
|
|
cookieCount: candidate.cookies.count,
|
|
signedInEmail: signed,
|
|
matchesCodexEmail: matches)
|
|
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
|
|
logger("Selected \(candidate.label) but dashboard still requires login.")
|
|
throw ImportError.dashboardStillRequiresLogin
|
|
} catch {
|
|
OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
|
|
throw Self.persistentValidationFailure(error)
|
|
}
|
|
}
|
|
|
|
private func persistToDefaultStore(
|
|
candidate: Candidate,
|
|
deadline: Date?,
|
|
logger: @escaping (String) -> Void) async throws -> ImportResult
|
|
{
|
|
let persistent = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: nil)
|
|
try await self.clearChatGPTCookies(in: persistent, deadline: deadline)
|
|
try await self.setCookies(candidate.cookies, into: persistent, deadline: deadline)
|
|
|
|
do {
|
|
let probe = try await OpenAIDashboardFetcher().probeUsagePage(
|
|
websiteDataStore: persistent,
|
|
logger: logger,
|
|
timeout: Self.remainingTimeout(until: deadline, cappedAt: 20),
|
|
preserveLoadedPageForReuse: true)
|
|
let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
logger("Persistent session signed in as: \(signed ?? "unknown")")
|
|
return ImportResult(
|
|
sourceLabel: candidate.label,
|
|
cookieCount: candidate.cookies.count,
|
|
signedInEmail: signed,
|
|
matchesCodexEmail: false)
|
|
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
|
|
logger("Selected \(candidate.label) but dashboard still requires login.")
|
|
throw ImportError.dashboardStillRequiresLogin
|
|
} catch {
|
|
OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Candidates
|
|
|
|
private func cookies(from pairs: [(name: String, value: String)]) -> [HTTPCookie] {
|
|
var cookies: [HTTPCookie] = []
|
|
for domain in Self.cookieDomains {
|
|
for pair in pairs {
|
|
let props: [HTTPCookiePropertyKey: Any] = [
|
|
.name: pair.name,
|
|
.value: pair.value,
|
|
.domain: domain,
|
|
.path: "/",
|
|
.secure: true,
|
|
]
|
|
if let cookie = HTTPCookie(properties: props) {
|
|
cookies.append(cookie)
|
|
}
|
|
}
|
|
}
|
|
return cookies
|
|
}
|
|
|
|
private func cacheCookies(
|
|
candidate: Candidate,
|
|
scope: CookieHeaderCache.Scope?,
|
|
deadline: Date?,
|
|
logger: @escaping (String) -> Void) async
|
|
{
|
|
let header = self.cookieHeader(from: candidate.cookies)
|
|
guard !header.isEmpty else { return }
|
|
do {
|
|
_ = try await Self.runBoundedCookieCacheOperation(deadline: deadline) {
|
|
CookieHeaderCache.store(
|
|
provider: .codex,
|
|
scope: scope,
|
|
cookieHeader: header,
|
|
sourceLabel: candidate.label)
|
|
return true
|
|
}
|
|
} catch {
|
|
logger("Cookie cache write did not finish before the web deadline.")
|
|
}
|
|
}
|
|
|
|
private func cookieHeader(from cookies: [HTTPCookie]) -> String {
|
|
cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
|
}
|
|
|
|
private struct Candidate {
|
|
let label: String
|
|
let cookies: [HTTPCookie]
|
|
}
|
|
|
|
// MARK: - WebKit cookie store
|
|
|
|
private func persistCookies(
|
|
candidate: Candidate,
|
|
accountEmail: String,
|
|
cacheScope: CookieHeaderCache.Scope?,
|
|
deadline: Date?,
|
|
logger: (String) -> Void) async throws
|
|
{
|
|
let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail, scope: cacheScope)
|
|
try await self.clearChatGPTCookies(in: store, deadline: deadline)
|
|
try await self.setCookies(candidate.cookies, into: store, deadline: deadline)
|
|
logger("Persisted cookies for \(accountEmail) (source=\(candidate.label))")
|
|
}
|
|
|
|
private func clearChatGPTCookies(in store: WKWebsiteDataStore, deadline: Date?) async throws {
|
|
try await Self.runSerializedCallback(
|
|
key: ObjectIdentifier(store),
|
|
deadline: deadline)
|
|
{ completion in
|
|
store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
|
|
let filtered = records.filter { record in
|
|
let name = record.displayName.lowercased()
|
|
return name.contains("chatgpt.com") || name.contains("openai.com")
|
|
}
|
|
store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: filtered) {
|
|
completion()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setCookies(
|
|
_ cookies: [HTTPCookie],
|
|
into store: WKWebsiteDataStore,
|
|
deadline: Date?) async throws
|
|
{
|
|
for cookie in cookies {
|
|
try await Self.runSerializedCallback(
|
|
key: ObjectIdentifier(store),
|
|
deadline: deadline)
|
|
{ completion in
|
|
store.httpCookieStore.setCookie(cookie, completionHandler: completion)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cookieSummary(_ cookies: [HTTPCookie]) -> String {
|
|
let nameCounts = Dictionary(grouping: cookies, by: \.name).mapValues { $0.count }
|
|
let important = [
|
|
"__Secure-next-auth.session-token",
|
|
"__Secure-next-auth.session-token.0",
|
|
"__Secure-next-auth.session-token.1",
|
|
"_account",
|
|
"oai-did",
|
|
"cf_clearance",
|
|
]
|
|
let parts: [String] = important.compactMap { name -> String? in
|
|
guard let c = nameCounts[name], c > 0 else { return nil }
|
|
return "\(name)=\(c)"
|
|
}
|
|
if parts.isEmpty { return "no key cookies detected" }
|
|
return parts.joined(separator: ", ")
|
|
}
|
|
}
|
|
#else
|
|
import Foundation
|
|
|
|
@MainActor
|
|
public struct OpenAIDashboardBrowserCookieImporter {
|
|
public struct FoundAccount: Sendable, Hashable {
|
|
public let sourceLabel: String
|
|
public let email: String
|
|
|
|
public init(sourceLabel: String, email: String) {
|
|
self.sourceLabel = sourceLabel
|
|
self.email = email
|
|
}
|
|
}
|
|
|
|
public enum ImportError: LocalizedError {
|
|
case noCookiesFound
|
|
case browserAccessDenied(details: String)
|
|
case browserCookieLoadTimedOut(details: String)
|
|
case dashboardStillRequiresLogin
|
|
case noMatchingAccount(found: [FoundAccount])
|
|
case manualCookieHeaderInvalid
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noCookiesFound:
|
|
return "No browser cookies found."
|
|
case let .browserAccessDenied(details):
|
|
return "Browser cookie access denied. \(details)"
|
|
case let .browserCookieLoadTimedOut(details):
|
|
return "Browser cookie loading timed out. \(details)"
|
|
case .dashboardStillRequiresLogin:
|
|
return "Browser cookies imported, but dashboard still requires login."
|
|
case let .noMatchingAccount(found):
|
|
if found.isEmpty { return "No matching OpenAI web session found in browsers." }
|
|
let display = 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: ", ")
|
|
return "OpenAI web session does not match Codex account. Found: \(display)."
|
|
case .manualCookieHeaderInvalid:
|
|
return "Manual cookie header is missing a valid OpenAI session cookie."
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ImportResult: Sendable {
|
|
public let sourceLabel: String
|
|
public let cookieCount: Int
|
|
public let signedInEmail: String?
|
|
public let matchesCodexEmail: Bool
|
|
|
|
public init(sourceLabel: String, cookieCount: Int, signedInEmail: String?, matchesCodexEmail: Bool) {
|
|
self.sourceLabel = sourceLabel
|
|
self.cookieCount = cookieCount
|
|
self.signedInEmail = signedInEmail
|
|
self.matchesCodexEmail = matchesCodexEmail
|
|
}
|
|
}
|
|
|
|
public init() {}
|
|
|
|
public func importBestCookies(
|
|
intoAccountEmail _: String?,
|
|
allowAnyAccount _: Bool = false,
|
|
preferCachedCookieHeader _: Bool = true,
|
|
cacheScope _: CookieHeaderCache.Scope? = nil,
|
|
deadline _: Date? = nil,
|
|
logger _: ((String) -> Void)? = nil) async throws -> ImportResult
|
|
{
|
|
throw ImportError.browserAccessDenied(details: "OpenAI web cookie import is only supported on macOS.")
|
|
}
|
|
|
|
public func importManualCookies(
|
|
cookieHeader _: String,
|
|
intoAccountEmail _: String?,
|
|
allowAnyAccount _: Bool = false,
|
|
cacheScope _: CookieHeaderCache.Scope? = nil,
|
|
deadline _: Date? = nil,
|
|
logger _: ((String) -> Void)? = nil) async throws -> ImportResult
|
|
{
|
|
throw ImportError.browserAccessDenied(details: "OpenAI web cookie import is only supported on macOS.")
|
|
}
|
|
}
|
|
#endif
|