Files
steipete--codexbar/Sources/CodexBar/CodexAccountPromotionPreparation.swift
T
2026-07-13 12:22:33 +08:00

347 lines
13 KiB
Swift

import CodexBarCore
import Foundation
struct PreparedIdentity: Equatable {
let email: String?
let identity: CodexIdentity
let providerAccountID: String?
let workspaceLabel: String?
let workspaceAccountID: String?
}
struct PreparedAuthMaterial {
let homeURL: URL
let rawData: Data
let credentials: CodexOAuthCredentials
let runtimeAccount: CodexAuthBackedAccount
let authIdentity: PreparedIdentity
}
enum PreparedManagedHomeState {
case readable(PreparedAuthMaterial)
case missing(homeURL: URL)
case unreadable(homeURL: URL)
}
struct PreparedStoredManagedAccount {
let persisted: ManagedCodexAccount
let persistedIdentity: PreparedIdentity
let homeState: PreparedManagedHomeState
var authIdentity: PreparedIdentity? {
switch self.homeState {
case let .readable(authMaterial):
authMaterial.authIdentity
case .missing, .unreadable:
nil
}
}
}
enum PreparedLiveHomeState {
case missing(homeURL: URL)
case unreadable(homeURL: URL)
case apiKeyOnly(PreparedAuthMaterial)
case readable(PreparedAuthMaterial)
}
struct PreparedLiveAccount {
let homeState: PreparedLiveHomeState
var homeURL: URL {
switch self.homeState {
case let .missing(homeURL), let .unreadable(homeURL):
homeURL
case let .apiKeyOnly(authMaterial), let .readable(authMaterial):
authMaterial.homeURL
}
}
var authIdentity: PreparedIdentity? {
switch self.homeState {
case let .apiKeyOnly(authMaterial), let .readable(authMaterial):
authMaterial.authIdentity
case .missing, .unreadable:
nil
}
}
}
struct PreparedPromotionContext {
let snapshot: CodexAccountReconciliationSnapshot
let managedAccounts: ManagedCodexAccountSet
let storedManagedAccounts: [PreparedStoredManagedAccount]
let target: PreparedStoredManagedAccount
let live: PreparedLiveAccount
}
@MainActor
struct PreparedPromotionContextBuilder {
private let store: any ManagedCodexAccountStoring
private let workspaceResolver: any ManagedCodexWorkspaceResolving
private let snapshotLoader: any CodexAccountReconciliationSnapshotLoading
private let authMaterialReader: any CodexAuthMaterialReading
private let baseEnvironment: [String: String]
private let fileManager: FileManager
init(
store: any ManagedCodexAccountStoring,
workspaceResolver: any ManagedCodexWorkspaceResolving,
snapshotLoader: any CodexAccountReconciliationSnapshotLoading,
authMaterialReader: any CodexAuthMaterialReading,
baseEnvironment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default)
{
self.store = store
self.workspaceResolver = workspaceResolver
self.snapshotLoader = snapshotLoader
self.authMaterialReader = authMaterialReader
self.baseEnvironment = baseEnvironment
self.fileManager = fileManager
}
func build(targetID: UUID) async throws -> PreparedPromotionContext {
let snapshot = self.snapshotLoader.loadSnapshot()
let managedAccounts = try self.store.loadAccounts()
var preparedAccounts: [PreparedStoredManagedAccount] = []
preparedAccounts.reserveCapacity(managedAccounts.accounts.count)
for account in managedAccounts.accounts {
let preparedAccount = try await self.prepareStoredManagedAccount(account)
preparedAccounts.append(preparedAccount)
}
guard let target = preparedAccounts.first(where: { $0.persisted.id == targetID }) else {
throw CodexAccountPromotionError.targetManagedAccountNotFound
}
let live = await self.prepareLiveAccount()
return PreparedPromotionContext(
snapshot: snapshot,
managedAccounts: managedAccounts,
storedManagedAccounts: preparedAccounts,
target: target,
live: live)
}
private func prepareStoredManagedAccount(
_ account: ManagedCodexAccount) async throws
-> PreparedStoredManagedAccount
{
let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true)
let persistedIdentity = Self.persistedIdentity(from: account)
let homeState = await self.prepareManagedHomeState(homeURL: homeURL)
return PreparedStoredManagedAccount(
persisted: account,
persistedIdentity: persistedIdentity,
homeState: homeState)
}
private func prepareManagedHomeState(homeURL: URL) async -> PreparedManagedHomeState {
let readResult = self.readAuthData(homeURL: homeURL)
switch readResult {
case .missing:
return .missing(homeURL: homeURL)
case .unreadable:
return .unreadable(homeURL: homeURL)
case let .readable(rawData):
guard let authMaterial = await self.inspectAuthMaterial(homeURL: homeURL, rawData: rawData) else {
return .unreadable(homeURL: homeURL)
}
return .readable(authMaterial)
}
}
private func prepareLiveAccount() async -> PreparedLiveAccount {
let liveHomeURL = self.liveHomeURL()
let readResult = self.readAuthData(homeURL: liveHomeURL)
switch readResult {
case .missing:
return PreparedLiveAccount(homeState: .missing(homeURL: liveHomeURL))
case .unreadable:
return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL))
case let .readable(rawData):
guard let authMaterial = await self.inspectAuthMaterial(homeURL: liveHomeURL, rawData: rawData) else {
return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL))
}
if Self.isAPIKeyOnly(credentials: authMaterial.credentials, rawData: authMaterial.rawData) {
return PreparedLiveAccount(homeState: .apiKeyOnly(authMaterial))
}
return PreparedLiveAccount(homeState: .readable(authMaterial))
}
}
private func inspectAuthMaterial(homeURL: URL, rawData: Data) async -> PreparedAuthMaterial? {
guard let credentials = try? CodexOAuthCredentialsStore.parse(data: rawData),
let runtimeAccount = try? Self.runtimeAccount(from: rawData)
else {
return nil
}
let authIdentity = await self.derivedIdentity(
homePath: homeURL.path,
runtimeAccount: runtimeAccount)
return PreparedAuthMaterial(
homeURL: homeURL,
rawData: rawData,
credentials: credentials,
runtimeAccount: runtimeAccount,
authIdentity: authIdentity)
}
private func derivedIdentity(homePath: String, runtimeAccount: CodexAuthBackedAccount) async -> PreparedIdentity {
let normalizedEmail = Self.normalizeEmail(runtimeAccount.email)
let normalizedIdentity = Self.normalizedIdentity(runtimeAccount.identity, email: normalizedEmail)
let providerAccountID: String? = switch normalizedIdentity {
case let .providerAccount(id):
ManagedCodexAccount.normalizeProviderAccountID(id)
case .emailOnly, .unresolved:
nil
}
let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let providerAccountID {
await self.workspaceResolver.resolveWorkspaceIdentity(
homePath: homePath,
providerAccountID: providerAccountID)
} else {
nil
}
return PreparedIdentity(
email: normalizedEmail,
identity: normalizedIdentity,
providerAccountID: providerAccountID,
workspaceLabel: workspaceIdentity?.workspaceLabel,
workspaceAccountID: workspaceIdentity?.workspaceAccountID ?? providerAccountID)
}
private static func persistedIdentity(from account: ManagedCodexAccount) -> PreparedIdentity {
let normalizedEmail = Self.normalizeEmail(account.email)
let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(account.providerAccountID)
let identity = Self.normalizedIdentity(
CodexIdentityResolver.resolve(accountId: providerAccountID, email: normalizedEmail),
email: normalizedEmail)
return PreparedIdentity(
email: normalizedEmail,
identity: identity,
providerAccountID: providerAccountID,
workspaceLabel: account.workspaceLabel,
workspaceAccountID: account.workspaceAccountID)
}
private func liveHomeURL() -> URL {
CodexHomeScope.ambientHomeURL(env: self.baseEnvironment, fileManager: self.fileManager)
}
private func readAuthData(homeURL: URL) -> PreparedAuthReadState {
do {
let rawData = try self.authMaterialReader.readAuthData(homeURL: homeURL)
guard let rawData else {
return .missing
}
return .readable(rawData)
} catch {
return .unreadable
}
}
private static func runtimeAccount(from rawData: Data) throws -> CodexAuthBackedAccount {
guard let json = try JSONSerialization.jsonObject(with: rawData) as? [String: Any] else {
throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON")
}
let tokens = json["tokens"] as? [String: Any]
let idToken = tokens.flatMap {
Self.nonEmptyString(in: $0, snakeCaseKey: "id_token", camelCaseKey: "idToken")
}
let payload = idToken.flatMap(UsageFetcher.parseJWT)
let authDict = payload?["https://api.openai.com/auth"] as? [String: Any]
let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any]
let email = Self.normalizeEmail(
(payload?["email"] as? String) ?? (profileDict?["email"] as? String))
let plan = Self.normalizedField(
(authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String))
let accountID = ManagedCodexAccount.normalizeProviderAccountID(
tokens.flatMap {
Self.nonEmptyString(in: $0, snakeCaseKey: "account_id", camelCaseKey: "accountId")
}
?? (authDict?["chatgpt_account_id"] as? String)
?? (payload?["chatgpt_account_id"] as? String))
let identity = Self.normalizedIdentity(
CodexIdentityResolver.resolve(accountId: accountID, email: email),
email: email)
return CodexAuthBackedAccount(identity: identity, email: email, plan: plan)
}
private static func normalizedField(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
return value
}
private static func normalizeEmail(_ email: String?) -> String? {
CodexIdentityResolver.normalizeEmail(email)
}
private static func normalizedIdentity(_ identity: CodexIdentity, email: String?) -> CodexIdentity {
guard let email else { return identity }
return CodexIdentityMatcher.normalized(identity, fallbackEmail: email)
}
private static func isAPIKeyOnly(credentials: CodexOAuthCredentials, rawData: Data) -> Bool {
guard self.hasUsableOAuthTokens(in: rawData) == false else {
return false
}
return credentials.refreshToken.isEmpty
&& credentials.idToken == nil
&& credentials.accountId == nil
&& credentials.lastRefresh == nil
}
private static func hasUsableOAuthTokens(in rawData: Data) -> Bool {
guard let json = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any],
let tokens = json["tokens"] as? [String: Any]
else {
return false
}
let accessToken = self.nonEmptyString(
in: tokens,
snakeCaseKey: "access_token",
camelCaseKey: "accessToken")
let refreshToken = self.nonEmptyString(
in: tokens,
snakeCaseKey: "refresh_token",
camelCaseKey: "refreshToken")
return accessToken != nil && refreshToken != nil
}
private static func nonEmptyString(
in dictionary: [String: Any],
snakeCaseKey: String,
camelCaseKey: String)
-> String?
{
if let value = dictionary[snakeCaseKey] as? String,
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return value
}
if let value = dictionary[camelCaseKey] as? String,
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return value
}
return nil
}
}
private enum PreparedAuthReadState {
case missing
case unreadable
case readable(Data)
}