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

498 lines
20 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
protocol ManagedCodexHomeProducing: Sendable {
func makeHomeURL() -> URL
func validateManagedHomeForDeletion(_ url: URL) throws
}
protocol ManagedCodexLoginRunning: Sendable {
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result
}
protocol ManagedCodexIdentityReading: Sendable {
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount
}
protocol ManagedCodexWorkspaceResolving: Sendable {
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity?
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity]
}
extension ManagedCodexWorkspaceResolving {
func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] {
[]
}
}
protocol ManagedCodexWorkspaceSelecting: Sendable {
@MainActor
func selectWorkspace(
email: String,
currentWorkspaceID: String?,
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
}
enum ManagedCodexAccountServiceError: Error, Equatable {
case loginFailed(CodexLoginRunner.Result)
case missingEmail
case workspaceSelectionCancelled
case unsafeManagedHome(String)
}
extension ManagedCodexAccountServiceError {
var userFacingMessage: String {
switch self {
case let .loginFailed(result):
CodexLoginAlertPresentation.managedLoginFailureMessage(for: result)
case .missingEmail:
L("managed_login_missing_email")
case .workspaceSelectionCancelled:
L("workspace_selection_cancelled")
case let .unsafeManagedHome(path):
String(format: L("unsafe_managed_home"), path)
}
}
}
struct ManagedCodexHomeFactory: ManagedCodexHomeProducing {
let root: URL
init(root: URL = Self.defaultRootURL(), fileManager: FileManager = .default) {
let standardizedRoot = root.standardizedFileURL
if standardizedRoot.path != root.path {
self.root = standardizedRoot
} else {
self.root = root
}
_ = fileManager
}
func makeHomeURL() -> URL {
self.root.appendingPathComponent(UUID().uuidString, isDirectory: true)
}
func validateManagedHomeForDeletion(_ url: URL) throws {
let rootPath = self.root.standardizedFileURL.path
let targetPath = url.standardizedFileURL.path
let rootPrefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
guard targetPath.hasPrefix(rootPrefix), targetPath != rootPath else {
throw ManagedCodexAccountServiceError.unsafeManagedHome(url.path)
}
}
static func defaultRootURL(fileManager: FileManager = .default) -> URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser
return base
.appendingPathComponent("CodexBar", isDirectory: true)
.appendingPathComponent("managed-codex-homes", isDirectory: true)
}
}
struct DefaultManagedCodexLoginRunner: ManagedCodexLoginRunning {
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result {
await CodexLoginRunner.run(homePath: homePath, timeout: timeout)
}
}
struct DefaultManagedCodexIdentityReader: ManagedCodexIdentityReading {
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
return UsageFetcher(environment: env).loadAuthBackedCodexAccount()
}
}
struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving {
private let workspaceCache: CodexOpenAIWorkspaceIdentityCache
init(
workspaceCache: CodexOpenAIWorkspaceIdentityCache = CodexOpenAIWorkspaceIdentityCache())
{
self.workspaceCache = workspaceCache
}
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? {
let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID)
?? providerAccountID
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
if let credentials = try? CodexOAuthCredentialsStore.load(env: env),
let authoritativeIdentity = try? await CodexOpenAIWorkspaceResolver.resolve(credentials: credentials)
{
try? self.workspaceCache.store(authoritativeIdentity)
return authoritativeIdentity
}
let cachedLabel = self.workspaceCache.workspaceLabel(for: normalizedProviderAccountID)
return CodexOpenAIWorkspaceIdentity(
workspaceAccountID: normalizedProviderAccountID,
workspaceLabel: cachedLabel)
}
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
guard let credentials = try? CodexOAuthCredentialsStore.load(env: env),
let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials)
else {
return []
}
for identity in identities {
try? self.workspaceCache.store(identity)
}
return identities
}
}
struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting {
@MainActor
func selectWorkspace(
email: String,
currentWorkspaceID: String?,
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
{
guard workspaces.count > 1 else { return workspaces.first }
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false)
let sortedWorkspaces = workspaces.sorted { lhs, rhs in
self.workspaceTitle(lhs) < self.workspaceTitle(rhs)
}
for workspace in sortedWorkspaces {
popup.addItem(withTitle: self.workspaceTitle(workspace))
popup.lastItem?.representedObject = workspace.workspaceAccountID
}
if let currentWorkspaceID,
let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID })
{
popup.selectItem(at: selectedIndex)
}
let alert = NSAlert()
alert.messageText = L("Choose Codex workspace")
alert.informativeText = String(format: L("multiple_workspaces_found"), email)
alert.alertStyle = .informational
alert.accessoryView = popup
alert.addButton(withTitle: L("Add Workspace"))
alert.addButton(withTitle: L("Cancel"))
guard alert.runModal() == .alertFirstButtonReturn else {
return nil
}
let selectedWorkspaceID = popup.selectedItem?.representedObject as? String
return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID }
}
private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String {
workspace.workspaceLabel ?? workspace.workspaceAccountID
}
}
@MainActor
final class ManagedCodexAccountService {
private let store: any ManagedCodexAccountStoring
private let homeFactory: any ManagedCodexHomeProducing
private let loginRunner: any ManagedCodexLoginRunning
private let identityReader: any ManagedCodexIdentityReading
private let workspaceResolver: any ManagedCodexWorkspaceResolving
private let workspaceSelector: any ManagedCodexWorkspaceSelecting
private let fileManager: FileManager
init(
store: any ManagedCodexAccountStoring,
homeFactory: any ManagedCodexHomeProducing,
loginRunner: any ManagedCodexLoginRunning,
identityReader: any ManagedCodexIdentityReading,
workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(),
workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(),
fileManager: FileManager = .default)
{
self.store = store
self.homeFactory = homeFactory
self.loginRunner = loginRunner
self.identityReader = identityReader
self.workspaceResolver = workspaceResolver
self.workspaceSelector = workspaceSelector
self.fileManager = fileManager
}
convenience init(fileManager: FileManager = .default) {
self.init(
store: FileManagedCodexAccountStore(fileManager: fileManager),
homeFactory: ManagedCodexHomeFactory(fileManager: fileManager),
loginRunner: DefaultManagedCodexLoginRunner(),
identityReader: DefaultManagedCodexIdentityReader(),
workspaceResolver: DefaultManagedCodexWorkspaceResolver(),
workspaceSelector: CodexWorkspaceAlertSelector(),
fileManager: fileManager)
}
func authenticateManagedAccount(
existingAccountID: UUID? = nil,
timeout: TimeInterval = 120)
async throws -> ManagedCodexAccount
{
let snapshot = try self.store.loadAccounts()
let homeURL = self.homeFactory.makeHomeURL()
try self.fileManager.createDirectory(at: homeURL, withIntermediateDirectories: true)
let account: ManagedCodexAccount
let existingHomePathsToDelete: [String]
do {
let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout)
guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed(result) }
let identity = try self.identityReader.loadAccountIdentity(homePath: homeURL.path)
guard let rawEmail = identity.email?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawEmail.isEmpty
else {
throw ManagedCodexAccountServiceError.missingEmail
}
let authenticatedProviderAccountID: String? = switch identity.identity {
case let .providerAccount(id):
ManagedCodexAccount.normalizeProviderAccountID(id)
case .emailOnly, .unresolved:
nil
}
let selectedWorkspace = try await self.selectedWorkspaceIdentity(
email: rawEmail,
homePath: homeURL.path,
authenticatedProviderAccountID: authenticatedProviderAccountID)
let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID
let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace {
selectedWorkspace
} else {
await self.resolvedWorkspaceIdentity(
homePath: homeURL.path,
providerAccountID: providerAccountID)
}
let now = Date().timeIntervalSince1970
let existing = self.reconciledExistingAccount(
authenticatedEmail: rawEmail,
providerAccountID: providerAccountID,
existingAccountID: existingAccountID,
snapshot: snapshot)
let persistedMetadata = self.persistedProviderMetadata(
authenticatedProviderAccountID: providerAccountID,
resolvedWorkspaceIdentity: workspaceIdentity,
existingAccount: existing)
account = ManagedCodexAccount(
id: existing?.id ?? UUID(),
email: rawEmail,
providerAccountID: persistedMetadata.providerAccountID,
workspaceLabel: persistedMetadata.workspaceLabel,
workspaceAccountID: persistedMetadata.workspaceAccountID,
authFingerprint: CodexAuthFingerprint.fingerprint(
homePath: homeURL.path,
fileManager: self.fileManager),
managedHomePath: homeURL.path,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAuthenticatedAt: now)
let replacedAccountIDs = self.replacedAccountIDs(
authenticatedEmail: rawEmail,
providerAccountID: providerAccountID,
existingAccountID: existingAccountID,
matchedAccountID: existing?.id,
snapshot: snapshot)
existingHomePathsToDelete = snapshot.accounts
.filter { replacedAccountIDs.contains($0.id) }
.map(\.managedHomePath)
let updatedSnapshot = ManagedCodexAccountSet(
version: snapshot.version,
accounts: snapshot.accounts.filter { replacedAccountIDs.contains($0.id) == false } + [account])
try self.store.storeAccounts(updatedSnapshot)
} catch {
try? self.removeManagedHomeIfSafe(atPath: homeURL.path)
throw error
}
for existingHomePathToDelete in existingHomePathsToDelete where existingHomePathToDelete != homeURL.path {
try? self.removeManagedHomeIfSafe(atPath: existingHomePathToDelete)
}
return account
}
func removeManagedAccount(id: UUID) async throws {
let snapshot = try self.store.loadAccounts()
guard let account = snapshot.account(id: id) else { return }
let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true)
let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil
let remaining = snapshot.accounts.filter { $0.id != id }
try self.store.storeAccounts(ManagedCodexAccountSet(
version: snapshot.version,
accounts: remaining))
if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) {
try? self.fileManager.removeItem(at: homeURL)
}
}
private func removeManagedHomeIfSafe(atPath path: String) throws {
let homeURL = URL(fileURLWithPath: path, isDirectory: true)
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
if self.fileManager.fileExists(atPath: homeURL.path) {
try self.fileManager.removeItem(at: homeURL)
}
}
private func selectedWorkspaceIdentity(
email: String,
homePath: String,
authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity?
{
let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath)
guard workspaces.count > 1 else {
return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID }
}
guard let selected = await self.workspaceSelector.selectWorkspace(
email: email,
currentWorkspaceID: authenticatedProviderAccountID,
workspaces: workspaces)
else {
throw ManagedCodexAccountServiceError.workspaceSelectionCancelled
}
try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath)
return selected
}
private func resolvedWorkspaceIdentity(
homePath: String,
providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity?
{
guard let providerAccountID else { return nil }
return await self.workspaceResolver.resolveWorkspaceIdentity(
homePath: homePath,
providerAccountID: providerAccountID)
}
private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
let credentials = try CodexOAuthCredentialsStore.load(env: env)
try CodexOAuthCredentialsStore.save(
CodexOAuthCredentials(
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
idToken: credentials.idToken,
accountId: workspaceID,
lastRefresh: credentials.lastRefresh),
env: env)
}
private func reconciledExistingAccount(
authenticatedEmail: String,
providerAccountID: String?,
existingAccountID: UUID?,
snapshot: ManagedCodexAccountSet)
-> ManagedCodexAccount?
{
if let providerAccountID,
let existingByProviderAccountID = snapshot.account(
email: authenticatedEmail,
providerAccountID: providerAccountID)
{
return existingByProviderAccountID
}
if let existingAccountID,
let existingByID = snapshot.account(id: existingAccountID),
existingByID.email == Self.normalizeEmail(authenticatedEmail),
providerAccountID == nil || existingByID.providerAccountID == nil
{
return existingByID
}
guard providerAccountID == nil else {
return nil
}
// Email-only reconciliation is a legacy/hardening fallback. Once an auth payload carries a
// provider account ID, matching must stay on that ID so same-email workspaces can coexist.
return snapshot.account(email: authenticatedEmail)
}
private func replacedAccountIDs(
authenticatedEmail: String,
providerAccountID: String?,
existingAccountID: UUID?,
matchedAccountID: UUID?,
snapshot: ManagedCodexAccountSet) -> Set<UUID>
{
var ids: Set<UUID> = []
let normalizedEmail = Self.normalizeEmail(authenticatedEmail)
if let matchedAccountID {
ids.insert(matchedAccountID)
}
if providerAccountID != nil {
let legacySameEmailIDs = snapshot.accounts
.filter {
$0.id != matchedAccountID &&
$0.providerAccountID == nil &&
$0.email == normalizedEmail
}
.map(\.id)
ids.formUnion(legacySameEmailIDs)
}
guard let existingAccountID,
existingAccountID != matchedAccountID,
let existingByID = snapshot.account(id: existingAccountID)
else {
return ids
}
if existingByID.providerAccountID == nil,
existingByID.email == normalizedEmail,
providerAccountID != nil
{
ids.insert(existingAccountID)
}
return ids
}
private static func normalizeEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func persistedProviderMetadata(
authenticatedProviderAccountID: String?,
resolvedWorkspaceIdentity: CodexOpenAIWorkspaceIdentity?,
existingAccount: ManagedCodexAccount?) -> (
providerAccountID: String?,
workspaceLabel: String?,
workspaceAccountID: String?)
{
if let authenticatedProviderAccountID {
let isExistingProviderMatch = existingAccount?.providerAccountID == authenticatedProviderAccountID
return (
providerAccountID: authenticatedProviderAccountID,
workspaceLabel: resolvedWorkspaceIdentity?.workspaceLabel
?? (isExistingProviderMatch ? existingAccount?.workspaceLabel : nil),
workspaceAccountID: resolvedWorkspaceIdentity?.workspaceAccountID ??
(isExistingProviderMatch ? existingAccount?.workspaceAccountID : nil) ??
authenticatedProviderAccountID)
}
guard let existingAccount, existingAccount.providerAccountID != nil else {
return (providerAccountID: nil, workspaceLabel: nil, workspaceAccountID: nil)
}
return (
providerAccountID: existingAccount.providerAccountID,
workspaceLabel: existingAccount.workspaceLabel,
workspaceAccountID: existingAccount.workspaceAccountID ?? existingAccount.providerAccountID)
}
}