Files
steipete--codexbar/Sources/CodexBarCore/Providers/Codex/CodexVisibleAccountProjection.swift
T
2026-07-13 12:22:33 +08:00

288 lines
12 KiB
Swift

import Foundation
public struct CodexVisibleAccount: Equatable, Identifiable, Sendable {
public let id: String
public let email: String
public let workspaceLabel: String?
public let workspaceAccountID: String?
public let authFingerprint: String?
public let storedAccountID: UUID?
public let selectionSource: CodexActiveSource
public let isActive: Bool
public let isLive: Bool
public let canReauthenticate: Bool
public let canRemove: Bool
public init(
id: String,
email: String,
workspaceLabel: String? = nil,
workspaceAccountID: String? = nil,
authFingerprint: String? = nil,
storedAccountID: UUID?,
selectionSource: CodexActiveSource,
isActive: Bool,
isLive: Bool,
canReauthenticate: Bool,
canRemove: Bool)
{
self.id = id
self.email = email
self.workspaceLabel = Self.normalizeWorkspaceLabel(workspaceLabel)
self.workspaceAccountID = workspaceAccountID
self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint)
self.storedAccountID = storedAccountID
self.selectionSource = selectionSource
self.isActive = isActive
self.isLive = isLive
self.canReauthenticate = canReauthenticate
self.canRemove = canRemove
}
public var displayName: String {
guard let workspaceLabel else { return self.email }
return "\(self.email)\(workspaceLabel)"
}
public var menuDisplayName: String {
guard let menuWorkspaceLabel else { return self.email }
return "\(self.email)\(menuWorkspaceLabel)"
}
public var menuWorkspaceLabel: String? {
guard let workspaceLabel, workspaceLabel.compare("Personal", options: [.caseInsensitive]) != .orderedSame else {
return nil
}
return workspaceLabel
}
public var authenticationHealthLabel: String? {
guard !self.isLive, self.storedAccountID != nil, self.authFingerprint == nil else { return nil }
return "Missing auth"
}
private static func normalizeWorkspaceLabel(_ workspaceLabel: String?) -> String? {
guard let trimmed = workspaceLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
}
public struct CodexVisibleAccountProjection: Equatable, Sendable {
public let visibleAccounts: [CodexVisibleAccount]
public let activeVisibleAccountID: String?
public let liveVisibleAccountID: String?
public let hasUnreadableAddedAccountStore: Bool
public init(
visibleAccounts: [CodexVisibleAccount],
activeVisibleAccountID: String?,
liveVisibleAccountID: String?,
hasUnreadableAddedAccountStore: Bool)
{
self.visibleAccounts = visibleAccounts
self.activeVisibleAccountID = activeVisibleAccountID
self.liveVisibleAccountID = liveVisibleAccountID
self.hasUnreadableAddedAccountStore = hasUnreadableAddedAccountStore
}
public func source(forVisibleAccountID id: String) -> CodexActiveSource? {
self.visibleAccounts.first { $0.id == id }?.selectionSource
}
}
extension DefaultCodexAccountReconciler {
public func loadVisibleAccounts() -> CodexVisibleAccountProjection {
CodexVisibleAccountProjection.make(from: self.loadSnapshot())
}
}
extension CodexVisibleAccountProjection {
public static func make(from snapshot: CodexAccountReconciliationSnapshot) -> CodexVisibleAccountProjection {
let resolvedActiveSource = CodexActiveSourceResolver.resolve(from: snapshot).resolvedSource
var drafts: [VisibleAccountDraft] = []
for storedAccount in snapshot.storedAccounts {
let normalizedEmail = snapshot.runtimeEmail(for: storedAccount)
let runtimeIdentity = snapshot.runtimeIdentity(for: storedAccount)
let runtimeWorkspaceAccountID: String? = switch runtimeIdentity {
case let .providerAccount(id):
ManagedCodexAccount.normalizeWorkspaceAccountID(id)
case .emailOnly, .unresolved:
nil
}
drafts.append(VisibleAccountDraft(
email: normalizedEmail,
workspaceLabel: Self.normalizeWorkspaceLabel(storedAccount.workspaceLabel),
workspaceAccountID: storedAccount.workspaceAccountID ?? runtimeWorkspaceAccountID,
authFingerprint: storedAccount.authFingerprint,
storedAccountID: storedAccount.id,
selectionSource: .managedAccount(id: storedAccount.id),
isLive: false,
canReauthenticate: true,
canRemove: true,
identity: runtimeIdentity))
}
if let liveSystemAccount = snapshot.liveSystemAccount {
let normalizedEmail = Self.normalizeVisibleEmail(liveSystemAccount.email)
let liveIdentity = snapshot.runtimeIdentity(for: liveSystemAccount)
if let exactStoredAccountID = snapshot.matchingStoredAccountForLiveSystemAccount?.id,
let exactIndex = drafts.firstIndex(where: { $0.storedAccountID == exactStoredAccountID })
{
let existingDraft = drafts[exactIndex]
let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel)
drafts[exactIndex] = VisibleAccountDraft(
email: existingDraft.email,
workspaceLabel: liveWorkspaceLabel ?? existingDraft.workspaceLabel,
workspaceAccountID: liveSystemAccount.workspaceAccountID ?? existingDraft.workspaceAccountID,
authFingerprint: liveSystemAccount.authFingerprint ?? existingDraft.authFingerprint,
storedAccountID: existingDraft.storedAccountID,
selectionSource: .liveSystem,
isLive: true,
canReauthenticate: existingDraft.canReauthenticate,
canRemove: existingDraft.canRemove,
identity: liveIdentity)
} else if let existingIndex = drafts.firstIndex(where: { draft in
CodexIdentityMatcher.matches(
draft.identity,
lhsEmail: draft.email,
liveIdentity,
rhsEmail: normalizedEmail)
}) {
let existingDraft = drafts[existingIndex]
let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel)
drafts[existingIndex] = VisibleAccountDraft(
email: existingDraft.email,
workspaceLabel: liveWorkspaceLabel ?? existingDraft.workspaceLabel,
workspaceAccountID: liveSystemAccount.workspaceAccountID ?? existingDraft.workspaceAccountID,
authFingerprint: liveSystemAccount.authFingerprint ?? existingDraft.authFingerprint,
storedAccountID: existingDraft.storedAccountID,
selectionSource: .liveSystem,
isLive: true,
canReauthenticate: existingDraft.canReauthenticate,
canRemove: existingDraft.canRemove,
identity: liveIdentity)
} else {
drafts.append(VisibleAccountDraft(
email: normalizedEmail,
workspaceLabel: Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel),
workspaceAccountID: liveSystemAccount.workspaceAccountID,
authFingerprint: liveSystemAccount.authFingerprint,
storedAccountID: nil,
selectionSource: .liveSystem,
isLive: true,
canReauthenticate: true,
canRemove: false,
identity: liveIdentity))
}
}
let livePath = snapshot.liveSystemAccount.flatMap { CodexHomeScope.normalizedHomePath($0.codexHomePath) }
let managedPaths = Set(snapshot.storedAccounts.compactMap {
CodexHomeScope.normalizedHomePath($0.managedHomePath)
})
for profileAccount in snapshot.profileHomeAccounts {
let profilePath = CodexHomeScope.normalizedHomePath(profileAccount.codexHomePath)
guard let profilePath,
profilePath != livePath,
!managedPaths.contains(profilePath)
else {
continue
}
drafts.append(VisibleAccountDraft(
email: Self.normalizeVisibleEmail(profileAccount.email),
workspaceLabel: Self.normalizeWorkspaceLabel(profileAccount.workspaceLabel),
workspaceAccountID: profileAccount.workspaceAccountID,
authFingerprint: profileAccount.authFingerprint,
storedAccountID: nil,
selectionSource: .profileHome(path: profilePath),
isLive: false,
canReauthenticate: false,
canRemove: false,
identity: snapshot.runtimeIdentity(for: profileAccount)))
}
let groupedByEmail = Dictionary(grouping: drafts.indices, by: { drafts[$0].email })
let visibleAccounts = drafts.map { draft in
let id = Self.visibleAccountID(for: draft, emailGroupSize: groupedByEmail[draft.email]?.count ?? 0)
let isActive = switch resolvedActiveSource {
case .liveSystem:
draft.selectionSource == .liveSystem
case let .managedAccount(id):
draft.selectionSource == .managedAccount(id: id)
case let .profileHome(path):
draft.selectionSource == .profileHome(path: path)
}
return CodexVisibleAccount(
id: id,
email: draft.email,
workspaceLabel: draft.workspaceLabel,
workspaceAccountID: draft.workspaceAccountID,
authFingerprint: draft.authFingerprint,
storedAccountID: draft.storedAccountID,
selectionSource: draft.selectionSource,
isActive: isActive,
isLive: draft.isLive,
canReauthenticate: draft.canReauthenticate,
canRemove: draft.canRemove)
}.sorted { lhs, rhs in
if lhs.email != rhs.email {
return lhs.email < rhs.email
}
if lhs.isLive != rhs.isLive {
return lhs.isLive && !rhs.isLive
}
if lhs.displayName != rhs.displayName {
return lhs.displayName < rhs.displayName
}
return lhs.id < rhs.id
}
return CodexVisibleAccountProjection(
visibleAccounts: visibleAccounts,
activeVisibleAccountID: visibleAccounts.first { $0.isActive }?.id,
liveVisibleAccountID: visibleAccounts.first { $0.isLive }?.id,
hasUnreadableAddedAccountStore: snapshot.hasUnreadableAddedAccountStore)
}
private static func normalizeVisibleEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func normalizeWorkspaceLabel(_ workspaceLabel: String?) -> String? {
guard let trimmed = workspaceLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
private static func visibleAccountID(for draft: VisibleAccountDraft, emailGroupSize: Int) -> String {
guard emailGroupSize > 1 else { return draft.email }
switch draft.selectionSource {
case .liveSystem:
return "live:\(CodexIdentityMatcher.selectionKey(for: draft.identity, fallbackEmail: draft.email))"
case let .managedAccount(id):
return "managed:\(id.uuidString.lowercased())"
case let .profileHome(path):
return "profile:\(path)"
}
}
}
private struct VisibleAccountDraft {
let email: String
let workspaceLabel: String?
let workspaceAccountID: String?
let authFingerprint: String?
let storedAccountID: UUID?
let selectionSource: CodexActiveSource
let isLive: Bool
let canReauthenticate: Bool
let canRemove: Bool
let identity: CodexIdentity
}