154 lines
7.0 KiB
Swift
154 lines
7.0 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
|
|
/// External credential transactions must run to completion; configuration changes hide their state but do not
|
|
/// cancel the subprocess halfway through a claude-swap transaction.
|
|
struct ClaudeSwapTransientState {
|
|
var lastError: String?
|
|
var lastErrorAccountID: ProviderAccountIdentity?
|
|
var switchingAccountID: ProviderAccountIdentity?
|
|
var task: Task<Void, Never>?
|
|
var versionProbedPath: String?
|
|
}
|
|
|
|
extension UsageStore {
|
|
/// True when the opt-in claude-swap adapter should run alongside the
|
|
/// ambient Claude refresh. Listing is read-only; explicit account activation
|
|
/// stays external-process-owned and never exposes credentials to CodexBar.
|
|
func shouldFetchClaudeSwapAccounts() -> Bool {
|
|
self.isEnabled(.claude) && self.settings.claudeSwapEnabled &&
|
|
!self.settings.claudeSwapExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
func clearClaudeSwapAccountState() {
|
|
let hadState = !self.claudeSwapAccountSnapshots.isEmpty ||
|
|
self.claudeSwapLastRefreshAt != nil || self.claudeSwapLastError != nil ||
|
|
self.claudeSwapTransientState.lastError != nil ||
|
|
self.claudeSwapTransientState.lastErrorAccountID != nil ||
|
|
self.claudeSwapTransientState.switchingAccountID != nil
|
|
self.claudeSwapRefreshTask?.cancel()
|
|
self.claudeSwapRefreshTask = nil
|
|
self.claudeSwapAccountSnapshots = []
|
|
self.claudeSwapLastRefreshAt = nil
|
|
self.claudeSwapLastError = nil
|
|
self.claudeSwapTransientState.lastError = nil
|
|
self.claudeSwapTransientState.lastErrorAccountID = nil
|
|
self.claudeSwapTransientState.switchingAccountID = nil
|
|
if hadState {
|
|
self.claudeSwapRevision &+= 1
|
|
}
|
|
}
|
|
|
|
/// Runs the optional adapter independently so it cannot delay the ambient Claude card.
|
|
func scheduleClaudeSwapAccountRefresh(generation: UInt64? = nil) {
|
|
self.claudeSwapRefreshTask?.cancel()
|
|
guard self.shouldFetchClaudeSwapAccounts() else {
|
|
self.clearClaudeSwapAccountState()
|
|
return
|
|
}
|
|
|
|
self.claudeSwapRefreshTask = Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
await self.refreshClaudeSwapAccounts(generation: generation)
|
|
}
|
|
}
|
|
|
|
func refreshClaudeSwapAccounts(generation: UInt64? = nil) async {
|
|
let executablePath = self.settings.claudeSwapExecutablePath
|
|
await self.probeClaudeSwapVersionIfNeeded(executablePath: executablePath)
|
|
|
|
do {
|
|
let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath)
|
|
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list)
|
|
guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else {
|
|
return
|
|
}
|
|
self.claudeSwapAccountSnapshots = snapshots
|
|
self.claudeSwapLastRefreshAt = Date()
|
|
self.claudeSwapLastError = nil
|
|
self.claudeSwapRevision &+= 1
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else {
|
|
return
|
|
}
|
|
// Retain the last successful snapshots as stale data; the settings
|
|
// pane surfaces the adapter error and last refresh time.
|
|
let message = (error as? LocalizedError)?.errorDescription
|
|
?? error.localizedDescription
|
|
if self.claudeSwapLastError != message {
|
|
self.claudeSwapLastError = message
|
|
self.claudeSwapRevision &+= 1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Activates one account through the configured claude-swap executable.
|
|
/// The numeric slot comes from the already validated list payload; requests
|
|
/// are serialized so two credential transactions can never overlap.
|
|
func switchClaudeSwapAccount(_ accountID: ProviderAccountIdentity) {
|
|
guard self.claudeSwapTransientState.task == nil,
|
|
self.shouldFetchClaudeSwapAccounts(),
|
|
accountID.source == ClaudeSwapAccountProjection.sourceName,
|
|
let account = self.claudeSwapAccountSnapshots.first(where: { $0.id == accountID }),
|
|
account.canActivate,
|
|
let accountNumber = Int(accountID.opaqueID),
|
|
accountNumber > 0
|
|
else {
|
|
return
|
|
}
|
|
|
|
let executablePath = self.settings.claudeSwapExecutablePath
|
|
self.claudeSwapTransientState.switchingAccountID = accountID
|
|
self.claudeSwapTransientState.lastError = nil
|
|
self.claudeSwapTransientState.lastErrorAccountID = nil
|
|
self.claudeSwapRevision &+= 1
|
|
|
|
self.claudeSwapTransientState.task = Task { @MainActor [weak self] in
|
|
var switchError: String?
|
|
do {
|
|
_ = try await ClaudeSwapAccountReader.switchAccount(
|
|
executablePath: executablePath,
|
|
accountNumber: accountNumber)
|
|
} catch {
|
|
switchError = (error as? LocalizedError)?.errorDescription
|
|
?? error.localizedDescription
|
|
}
|
|
|
|
guard let self else { return }
|
|
if self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) {
|
|
// Claude Code owns the ambient credential, so reconcile both
|
|
// the provider snapshot and the adapter's active-row marker.
|
|
await self.refreshProvider(.claude)
|
|
}
|
|
let configurationIsCurrent = self.isCurrentClaudeSwapConfiguration(executablePath: executablePath)
|
|
self.claudeSwapTransientState.task = nil
|
|
self.claudeSwapTransientState.switchingAccountID = nil
|
|
if configurationIsCurrent {
|
|
self.claudeSwapTransientState.lastError = switchError
|
|
self.claudeSwapTransientState.lastErrorAccountID = switchError == nil ? nil : accountID
|
|
}
|
|
self.claudeSwapRevision &+= 1
|
|
}
|
|
}
|
|
|
|
private func probeClaudeSwapVersionIfNeeded(executablePath: String) async {
|
|
guard self.claudeSwapTransientState.versionProbedPath != executablePath else { return }
|
|
let version = await ClaudeSwapAccountReader.readVersion(executablePath: executablePath)
|
|
guard self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) else { return }
|
|
self.claudeSwapTransientState.versionProbedPath = executablePath
|
|
self.claudeSwapDetectedVersion = version
|
|
}
|
|
|
|
private func isCurrentClaudeSwapRefresh(executablePath: String, generation: UInt64?) -> Bool {
|
|
self.isCurrentProviderRefreshGeneration(.claude, generation: generation) &&
|
|
self.isCurrentClaudeSwapConfiguration(executablePath: executablePath)
|
|
}
|
|
|
|
private func isCurrentClaudeSwapConfiguration(executablePath: String) -> Bool {
|
|
self.isEnabled(.claude) && self.settings.claudeSwapEnabled &&
|
|
self.settings.claudeSwapExecutablePath == executablePath
|
|
}
|
|
}
|