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

354 lines
15 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
extension SettingsStore {
func tokenAccountsData(for provider: UsageProvider) -> ProviderTokenAccountData? {
guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil }
return self.configSnapshot.providerConfig(for: provider)?.tokenAccounts
}
func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] {
self.tokenAccountsData(for: provider)?.accounts ?? []
}
func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? {
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil }
let index = data.clampedActiveIndex()
return data.accounts[index]
}
func setActiveTokenAccountIndex(_ index: Int, for provider: UsageProvider) {
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
let clamped = min(max(index, 0), data.accounts.count - 1)
let updated = ProviderTokenAccountData(
version: data.version,
accounts: data.accounts,
activeIndex: clamped)
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
}
CodexBarLog.logger(LogCategories.tokenAccounts).info(
"Active token account updated",
metadata: [
"provider": provider.rawValue,
"index": "\(clamped)",
])
}
func addTokenAccount(
provider: UsageProvider,
label: String,
token: String,
externalIdentifier: String? = nil,
usageScope: String? = nil,
organizationID: String? = nil,
workspaceID: String? = nil)
{
guard TokenAccountSupportCatalog.support(for: provider) != nil else { return }
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedToken.isEmpty else { return }
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedIdentifier = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalisedIdentifier = (trimmedIdentifier?.isEmpty ?? true) ? nil : trimmedIdentifier
let trimmedUsageScope = usageScope?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalisedUsageScope = (trimmedUsageScope?.isEmpty ?? true) ? nil : trimmedUsageScope
let trimmedOrganizationID = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalisedOrganizationID = (trimmedOrganizationID?.isEmpty ?? true) ? nil : trimmedOrganizationID
let trimmedWorkspaceID = workspaceID?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalisedWorkspaceID = (trimmedWorkspaceID?.isEmpty ?? true) ? nil : trimmedWorkspaceID
let existing = self.tokenAccountsData(for: provider)
let accounts = existing?.accounts ?? []
let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel
let account = ProviderTokenAccount(
id: UUID(),
label: fallbackLabel,
token: trimmedToken,
addedAt: Date().timeIntervalSince1970,
lastUsed: nil,
externalIdentifier: normalisedIdentifier,
usageScope: normalisedUsageScope,
organizationID: normalisedOrganizationID,
workspaceID: normalisedWorkspaceID)
let updated = ProviderTokenAccountData(
version: existing?.version ?? 1,
accounts: accounts + [account],
activeIndex: accounts.count)
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
if provider == .copilot {
entry.apiKey = nil
}
}
self.applyTokenAccountCookieSourceIfNeeded(provider: provider)
CodexBarLog.logger(LogCategories.tokenAccounts).info(
"Token account added",
metadata: [
"provider": provider.rawValue,
"count": "\(updated.accounts.count)",
])
}
func updateTokenAccount(
provider: UsageProvider,
accountID: UUID,
label: String? = nil,
token: String? = nil,
externalIdentifier: String?? = nil,
usageScope: String?? = nil,
organizationID: String?? = nil,
workspaceID: String?? = nil)
{
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return }
let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedToken, trimmedToken.isEmpty { return }
let existing = data.accounts[index]
let resolvedIdentifier: String?
if let externalIdentifier {
let trimmed = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines)
resolvedIdentifier = (trimmed?.isEmpty ?? true) ? nil : trimmed
} else {
resolvedIdentifier = existing.externalIdentifier
}
let resolvedUsageScope: String?
if let usageScope {
let trimmed = usageScope?.trimmingCharacters(in: .whitespacesAndNewlines)
resolvedUsageScope = (trimmed?.isEmpty ?? true) ? nil : trimmed
} else {
resolvedUsageScope = existing.usageScope
}
let resolvedOrganizationID: String?
if let organizationID {
let trimmed = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines)
resolvedOrganizationID = (trimmed?.isEmpty ?? true) ? nil : trimmed
} else {
resolvedOrganizationID = existing.organizationID
}
let resolvedWorkspaceID: String?
if let workspaceID {
let trimmed = workspaceID?.trimmingCharacters(in: .whitespacesAndNewlines)
resolvedWorkspaceID = (trimmed?.isEmpty ?? true) ? nil : trimmed
} else {
resolvedWorkspaceID = existing.workspaceID
}
let updatedAccount = ProviderTokenAccount(
id: existing.id,
label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label,
token: trimmedToken ?? existing.token,
addedAt: existing.addedAt,
lastUsed: existing.lastUsed,
externalIdentifier: resolvedIdentifier,
usageScope: resolvedUsageScope,
organizationID: resolvedOrganizationID,
workspaceID: resolvedWorkspaceID)
var accounts = data.accounts
accounts[index] = updatedAccount
let updated = ProviderTokenAccountData(
version: data.version,
accounts: accounts,
activeIndex: data.clampedActiveIndex())
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
if provider == .copilot {
entry.apiKey = nil
}
}
self.applyTokenAccountCookieSourceIfNeeded(provider: provider)
CodexBarLog.logger(LogCategories.tokenAccounts).info(
"Token account updated",
metadata: [
"provider": provider.rawValue,
"count": "\(updated.accounts.count)",
])
}
func removeTokenAccount(provider: UsageProvider, accountID: UUID) {
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
let activeAccountID = data.accounts[data.clampedActiveIndex()].id
guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return }
let removedAccount = data.accounts[removedIndex]
let filtered = data.accounts.filter { $0.id != accountID }
self.updateProviderConfig(provider: provider) { entry in
if filtered.isEmpty {
entry.tokenAccounts = nil
} else {
let nextActiveIndex = if activeAccountID != accountID,
let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID })
{
preservedIndex
} else {
min(removedIndex, filtered.count - 1)
}
entry.tokenAccounts = ProviderTokenAccountData(
version: data.version,
accounts: filtered,
activeIndex: nextActiveIndex)
}
if provider == .copilot {
entry.apiKey = nil
}
}
self.applyTokenAccountRemovalSideEffectsIfNeeded(
provider: provider,
removedAccount: removedAccount,
remainingAccounts: filtered)
CodexBarLog.logger(LogCategories.tokenAccounts).info(
"Token account removed",
metadata: [
"provider": provider.rawValue,
"count": "\(filtered.count)",
])
}
func ensureTokenAccountsLoaded() {
if self.tokenAccountsLoaded { return }
self.tokenAccountsLoaded = true
}
func reloadTokenAccounts() {
let log = CodexBarLog.logger(LogCategories.tokenAccounts)
let accounts: [UsageProvider: ProviderTokenAccountData]
do {
guard let loaded = try self.configStore.load() else { return }
accounts = Dictionary(uniqueKeysWithValues: loaded.providers.compactMap { entry in
guard let data = entry.tokenAccounts else { return nil }
return (entry.id, data)
})
} catch {
log.error("Failed to reload token accounts: \(error)")
return
}
self.tokenAccountsLoaded = true
self.updateProviderTokenAccounts(accounts)
}
func openTokenAccountsFile() {
do {
try self.configStore.save(self.config)
} catch {
CodexBarLog.logger(LogCategories.tokenAccounts).error("Failed to persist config: \(error)")
return
}
NSWorkspace.shared.open(self.configStore.fileURL)
}
private func applyTokenAccountCookieSourceIfNeeded(provider: UsageProvider) {
guard let support = TokenAccountSupportCatalog.support(for: provider),
support.requiresManualCookieSource
else { return }
ProviderCatalog.implementation(for: provider)?.applyTokenAccountCookieSource(settings: self)
}
private func applyTokenAccountRemovalSideEffectsIfNeeded(
provider: UsageProvider,
removedAccount: ProviderTokenAccount,
remainingAccounts: [ProviderTokenAccount])
{
guard provider == .antigravity else { return }
guard let removedCredentials = AntigravityOAuthCredentialsStore.credentials(
fromTokenAccountValue: removedAccount.token)
else {
return
}
let hasMatchingRemainingAccount = remainingAccounts.contains { account in
guard let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token)
else {
return false
}
return Self.antigravityCredentialsMatchAccount(credentials, removedCredentials)
}
guard !hasMatchingRemainingAccount else { return }
Self.clearMatchingAntigravitySharedCredentials(
store: self.antigravityOAuthCredentialsStore,
removedCredentials: removedCredentials)
}
private nonisolated static func clearMatchingAntigravitySharedCredentials(
store: AntigravityOAuthCredentialsStore,
removedCredentials: AntigravityOAuthCredentials)
{
do {
try store.deleteIfPresent { sharedCredentials in
self.antigravitySharedCredentialsMatchRemovedAccount(
sharedCredentials,
removedCredentials)
}
} catch {
CodexBarLog.logger(LogCategories.tokenAccounts).warning(
"Failed to clear Antigravity OAuth cache after account removal",
metadata: ["error": error.localizedDescription])
}
}
private nonisolated static func antigravitySharedCredentialsMatchRemovedAccount(
_ shared: AntigravityOAuthCredentials,
_ removed: AntigravityOAuthCredentials) -> Bool
{
if let sharedRefreshToken = self.normalizedAntigravityCredentialToken(shared.refreshToken),
let removedRefreshToken = self.normalizedAntigravityCredentialToken(removed.refreshToken)
{
return sharedRefreshToken == removedRefreshToken
}
if let sharedAccessToken = self.normalizedAntigravityCredentialToken(shared.accessToken),
let removedAccessToken = self.normalizedAntigravityCredentialToken(removed.accessToken)
{
return sharedAccessToken == removedAccessToken
}
guard self.normalizedAntigravityCredentialToken(shared.refreshToken) == nil,
self.normalizedAntigravityCredentialToken(removed.refreshToken) == nil,
self.normalizedAntigravityCredentialToken(shared.accessToken) == nil,
self.normalizedAntigravityCredentialToken(removed.accessToken) == nil
else {
return false
}
return self.normalizedAntigravityAccountEmail(shared.resolvedAccountEmail)
== self.normalizedAntigravityAccountEmail(removed.resolvedAccountEmail)
}
private nonisolated static func antigravityCredentialsMatchAccount(
_ lhs: AntigravityOAuthCredentials,
_ rhs: AntigravityOAuthCredentials) -> Bool
{
if let lhsEmail = self.normalizedAntigravityAccountEmail(lhs.resolvedAccountEmail),
let rhsEmail = self.normalizedAntigravityAccountEmail(rhs.resolvedAccountEmail)
{
return lhsEmail == rhsEmail
}
if let lhsRefreshToken = self.normalizedAntigravityCredentialToken(lhs.refreshToken),
let rhsRefreshToken = self.normalizedAntigravityCredentialToken(rhs.refreshToken)
{
return lhsRefreshToken == rhsRefreshToken
}
if let lhsAccessToken = self.normalizedAntigravityCredentialToken(lhs.accessToken),
let rhsAccessToken = self.normalizedAntigravityCredentialToken(rhs.accessToken)
{
return lhsAccessToken == rhsAccessToken
}
return false
}
private nonisolated static func normalizedAntigravityAccountEmail(_ email: String?) -> String? {
guard let value = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!value.isEmpty
else {
return nil
}
return value
}
private nonisolated static func normalizedAntigravityCredentialToken(_ token: String?) -> String? {
guard let value = token?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
return nil
}
return value
}
}