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

278 lines
12 KiB
Swift

import CodexBarCore
import Foundation
private struct CodexPreparedImportedAccount {
let account: ManagedCodexAccount
let homeURL: URL
}
struct CodexDisplacedLivePreservationExecutionResult: Equatable {
let displacedLiveDisposition: CodexAccountPromotionResult.DisplacedLiveDisposition
}
@MainActor
struct CodexDisplacedLivePreservationExecutor {
private let store: any ManagedCodexAccountStoring
private let homeFactory: any ManagedCodexHomeProducing
private let fileManager: FileManager
init(
store: any ManagedCodexAccountStoring,
homeFactory: any ManagedCodexHomeProducing,
fileManager: FileManager = .default)
{
self.store = store
self.homeFactory = homeFactory
self.fileManager = fileManager
}
func execute(
plan: CodexDisplacedLivePreservationPlan,
context: PreparedPromotionContext) throws
-> CodexDisplacedLivePreservationExecutionResult
{
/*
Safety contract:
- This executor never swaps live auth. The caller must do that only after success.
- Import cleanup is best-effort and leaves no orphaned managed home on failure.
- Refresh/repair may copy auth before store commit, matching current behavior.
*/
switch plan {
case .none:
return CodexDisplacedLivePreservationExecutionResult(displacedLiveDisposition: .none)
case let .reject(reason):
throw self.error(for: reason)
case .importNew:
let importedAccount = try self.importDisplacedLiveAccount(from: context)
return try self.commitImportedAccount(importedAccount)
case let .refreshExisting(destination, _),
let .repairExisting(destination, _):
guard destination.persisted.id != context.target.persisted.id else {
throw CodexAccountPromotionError.managedStoreCommitFailed
}
let refreshed = try self.refreshExistingManagedAccount(destination, from: context)
return CodexDisplacedLivePreservationExecutionResult(
displacedLiveDisposition: .alreadyManaged(managedAccountID: refreshed.id))
}
}
private func error(for reason: CodexDisplacedLivePreservationRejectReason) -> CodexAccountPromotionError {
switch reason {
case .liveUnreadable:
.liveAccountUnreadable
case .liveAPIKeyOnlyUnsupported:
.liveAccountAPIKeyOnlyUnsupported
case .liveIdentityMissingForPreservation:
.liveAccountMissingIdentityForPreservation
case .conflictingReadableManagedHome:
.displacedLiveManagedAccountConflict
}
}
private func importDisplacedLiveAccount(
from context: PreparedPromotionContext) throws
-> CodexPreparedImportedAccount
{
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
throw CodexAccountPromotionError.displacedLiveImportFailed
}
let importedHomeURL = self.homeFactory.makeHomeURL()
let importedAccountID = Self.accountID(for: importedHomeURL)
do {
try self.fileManager.createDirectory(at: importedHomeURL, withIntermediateDirectories: true)
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: importedHomeURL)
guard let liveAuthIdentity = context.live.authIdentity,
let email = liveAuthIdentity.email,
liveAuthIdentity.identity != .unresolved
else {
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
}
let now = Date().timeIntervalSince1970
return CodexPreparedImportedAccount(
account: ManagedCodexAccount(
id: importedAccountID,
email: email,
providerAccountID: liveAuthIdentity.providerAccountID,
workspaceLabel: liveAuthIdentity.workspaceLabel,
workspaceAccountID: liveAuthIdentity.workspaceAccountID,
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
managedHomePath: importedHomeURL.path,
createdAt: now,
updatedAt: now,
lastAuthenticatedAt: now),
homeURL: importedHomeURL)
} catch let error as CodexAccountPromotionError {
try? self.removeManagedHomeIfSafe(importedHomeURL)
throw error
} catch {
try? self.removeManagedHomeIfSafe(importedHomeURL)
throw CodexAccountPromotionError.displacedLiveImportFailed
}
}
private func commitImportedAccount(_ importedAccount: CodexPreparedImportedAccount) throws
-> CodexDisplacedLivePreservationExecutionResult
{
do {
let latestManagedAccounts = try self.store.loadAccounts()
try self.store.storeAccounts(ManagedCodexAccountSet(
version: latestManagedAccounts.version,
accounts: latestManagedAccounts.accounts + [importedAccount.account]))
return try self.resolveImportedAccountAfterCommit(importedAccount)
} catch let error as CodexAccountPromotionError {
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
throw error
} catch {
try? self.removeManagedHomeIfSafe(importedAccount.homeURL)
throw CodexAccountPromotionError.managedStoreCommitFailed
}
}
private func resolveImportedAccountAfterCommit(_ importedAccount: CodexPreparedImportedAccount) throws
-> CodexDisplacedLivePreservationExecutionResult
{
let persistedManagedAccounts = try self.store.loadAccounts()
if persistedManagedAccounts.account(id: importedAccount.account.id) != nil {
return CodexDisplacedLivePreservationExecutionResult(
displacedLiveDisposition: .imported(managedAccountID: importedAccount.account.id))
}
guard let existingManagedAccount = self.repairDestination(
in: persistedManagedAccounts,
for: importedAccount.account)
else {
throw CodexAccountPromotionError.managedStoreCommitFailed
}
let repairedManagedAccount = ManagedCodexAccount(
id: existingManagedAccount.id,
email: importedAccount.account.email,
providerAccountID: importedAccount.account.providerAccountID,
workspaceLabel: importedAccount.account.workspaceLabel,
workspaceAccountID: importedAccount.account.workspaceAccountID,
authFingerprint: importedAccount.account.authFingerprint,
managedHomePath: importedAccount.homeURL.path,
createdAt: existingManagedAccount.createdAt,
updatedAt: importedAccount.account.updatedAt,
lastAuthenticatedAt: importedAccount.account.lastAuthenticatedAt)
try self.store.storeAccounts(ManagedCodexAccountSet(
version: persistedManagedAccounts.version,
accounts: persistedManagedAccounts.accounts.map { account in
guard account.id == existingManagedAccount.id else { return account }
return repairedManagedAccount
}))
if existingManagedAccount.managedHomePath != importedAccount.homeURL.path {
try? self.removeManagedHomeIfSafe(
URL(fileURLWithPath: existingManagedAccount.managedHomePath, isDirectory: true))
}
return CodexDisplacedLivePreservationExecutionResult(
displacedLiveDisposition: .alreadyManaged(managedAccountID: existingManagedAccount.id))
}
private func repairDestination(
in persistedManagedAccounts: ManagedCodexAccountSet,
for importedAccount: ManagedCodexAccount) -> ManagedCodexAccount?
{
if let providerAccountID = importedAccount.providerAccountID {
return persistedManagedAccounts.account(
email: importedAccount.email,
providerAccountID: providerAccountID)
}
let normalizedEmail = importedAccount.email
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return persistedManagedAccounts.accounts.first {
$0.email == normalizedEmail && $0.providerAccountID == nil
}
}
private func refreshExistingManagedAccount(
_ destination: PreparedStoredManagedAccount,
from context: PreparedPromotionContext) throws
-> ManagedCodexAccount
{
guard case let .readable(liveAuthMaterial) = context.live.homeState else {
throw CodexAccountPromotionError.managedStoreCommitFailed
}
guard let liveAuthIdentity = context.live.authIdentity else {
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
}
do {
let latestManagedAccounts = try self.store.loadAccounts()
guard let persistedManagedAccount = latestManagedAccounts.account(id: destination.persisted.id) else {
throw CodexAccountPromotionError.managedStoreCommitFailed
}
let email = liveAuthIdentity.email
?? (liveAuthIdentity.providerAccountID != nil ? persistedManagedAccount.email : nil)
guard let email, liveAuthIdentity.identity != .unresolved else {
throw CodexAccountPromotionError.liveAccountMissingIdentityForPreservation
}
let now = Date().timeIntervalSince1970
let refreshedManagedAccount = ManagedCodexAccount(
id: persistedManagedAccount.id,
email: email,
providerAccountID: liveAuthIdentity.providerAccountID ?? persistedManagedAccount.providerAccountID,
workspaceLabel: liveAuthIdentity.workspaceLabel ?? persistedManagedAccount.workspaceLabel,
workspaceAccountID: liveAuthIdentity.workspaceAccountID ?? persistedManagedAccount.workspaceAccountID,
authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData),
managedHomePath: persistedManagedAccount.managedHomePath,
createdAt: persistedManagedAccount.createdAt,
updatedAt: now,
lastAuthenticatedAt: now)
let refreshedHomeURL = URL(fileURLWithPath: persistedManagedAccount.managedHomePath, isDirectory: true)
do {
try self.homeFactory.validateManagedHomeForDeletion(refreshedHomeURL)
} catch {
throw CodexAccountPromotionError.displacedLiveImportFailed
}
try self.fileManager.createDirectory(at: refreshedHomeURL, withIntermediateDirectories: true)
try self.writeManagedAuthData(liveAuthMaterial.rawData, to: refreshedHomeURL)
try self.store.storeAccounts(ManagedCodexAccountSet(
version: latestManagedAccounts.version,
accounts: latestManagedAccounts.accounts.map { account in
guard account.id == persistedManagedAccount.id else { return account }
return refreshedManagedAccount
}))
return refreshedManagedAccount
} catch let error as CodexAccountPromotionError {
throw error
} catch {
throw CodexAccountPromotionError.managedStoreCommitFailed
}
}
private func writeManagedAuthData(_ data: Data, to homeURL: URL) throws {
let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL)
try data.write(to: authFileURL, options: .atomic)
try self.fileManager.setAttributes(
[.posixPermissions: NSNumber(value: Int16(0o600))],
ofItemAtPath: authFileURL.path)
}
private func removeManagedHomeIfSafe(_ homeURL: URL) throws {
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
if self.fileManager.fileExists(atPath: homeURL.path) {
try self.fileManager.removeItem(at: homeURL)
}
}
private static func accountID(for homeURL: URL) -> UUID {
UUID(uuidString: homeURL.lastPathComponent) ?? UUID()
}
}