diff --git a/Sources/App/APIKeyFreeSettingsRow.swift b/Sources/App/APIKeyFreeSettingsRow.swift new file mode 100644 index 0000000..1b397b8 --- /dev/null +++ b/Sources/App/APIKeyFreeSettingsRow.swift @@ -0,0 +1,131 @@ +import AppKit +import Core +import SwiftUI + +@MainActor +struct APIKeyFreeSettingsRow: View { + let provider: ProviderInfo + let state: ProviderState + let isEnabled: Bool + let canInstall: Bool + let credentialImportActionTitle: String? + let onEnabledChange: @MainActor @Sendable (Bool) -> Void + let onInstall: () -> Void + let onRemove: () -> Void + let onImportCredentials: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SettingsCardHeader( + provider: provider, + status: isEnabled ? ProviderStatusPresentation.apiKeyFree(state) : .disabled, + isEnabled: isEnabled, + onEnabledChange: onEnabledChange + ) + + Divider() + + if !isEnabled { + Text(String(localized: "Enable this provider to view setup options.")) + .font(.caption) + .foregroundColor(.secondary) + } else { + switch state { + case .loading: + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(String(localized: "Working…")) + .font(.caption) + .foregroundColor(.secondary) + } + case let .setup(reason): + VStack(alignment: .leading, spacing: 8) { + if canInstall { + installPromptView(reason: reason) + } else { + setupReasonView(reason: reason) + } + credentialImportButton + } + case .loaded: + removeHelperView + case let .error(message): + VStack(alignment: .leading, spacing: 8) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(ProviderVisualStyle.tierColor(.critical, scheme: colorScheme)) + credentialImportButton + } + case .unconfigured: + Text(String(localized: "Not configured")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + private func installPromptView(reason: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + setupReasonView(reason: reason) + + Text(String(localized: "Filbert reads your Claude Code usage by hooking into its status line.")) + .font(.caption) + .foregroundColor(.secondary) + Text(String(localized: "This adds a small helper script to ~/.claude/.")) + .font(.caption) + .foregroundColor(.secondary) + + Button(String(localized: "Install Helper")) { + onInstall() + } + .buttonStyle(.borderedProminent) + } + } + + private func setupReasonView(reason: String) -> some View { + HStack(spacing: 6) { + if let setupHelp = provider.setupHelp { + Button { + NSWorkspace.shared.open(setupHelp.url) + } label: { + Label(setupHelp.linkLabel, systemImage: "arrow.up.right") + } + .font(.caption) + .buttonStyle(.link) + .layoutPriority(1) + } + + Text(reason) + .font(.caption) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var credentialImportButton: some View { + if let credentialImportActionTitle { + Button(credentialImportActionTitle) { + onImportCredentials() + } + .buttonStyle(.bordered) + } + } + + private var removeHelperView: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Helper installed and active.")) + .font(.caption) + .foregroundColor(.secondary) + + Button(String(localized: "Remove Helper"), role: .destructive) { + onRemove() + } + } + } +} diff --git a/Sources/App/QuotaViewModel+Lifecycle.swift b/Sources/App/QuotaViewModel+Lifecycle.swift new file mode 100644 index 0000000..a0957ed --- /dev/null +++ b/Sources/App/QuotaViewModel+Lifecycle.swift @@ -0,0 +1,140 @@ +import Core +import Foundation + +extension QuotaViewModel { + func startAutoRefresh(for providerId: String) { + guard isReadyToFetch(providerId) else { return } + stopAutoRefresh(for: providerId) + let interval = refreshInterval + refreshLoops[providerId] = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(interval)) + guard !Task.isCancelled else { break } + await MainActor.run { [weak self] in + self?.fetchQuota(for: providerId) + } + } + } + } + + func stopAutoRefresh(for providerId: String) { + refreshLoops[providerId]?.cancel() + refreshLoops[providerId] = nil + } + + func isEnabled(_ providerId: String) -> Bool { + enabledProviderIds.contains(providerId) + } + + func setProviderEnabled(_ enabled: Bool, for providerId: String) { + guard providerInfo(for: providerId) != nil else { return } + registry.setEnabled(enabled, for: providerId) + + var ids = enabledProviderIds + if enabled { + ids.insert(providerId) + } else { + ids.remove(providerId) + } + enabledProviderIds = ids + + invalidateProviderWork(for: providerId) + if enabled { + startEnabledProvider(for: providerId) + } else if case .loading = providerStates[providerId] { + setState(.unconfigured, for: providerId) + } + refreshDerived() + } + + func startEnabledProvider(for providerId: String) { + guard isEnabled(providerId), let info = providerInfo(for: providerId) else { return } + + switch info.authShape { + case .apiKey: + guard registry.isConfigured(providerId) else { + setState(.unconfigured, for: providerId) + refreshDerived() + return + } + startAutoRefresh(for: providerId) + performFetch(for: providerId) + case .apiKeyFree: + let revision = lifecycleRevisions[providerId, default: 0] + setupTasks[providerId]?.cancel() + setupTasks[providerId] = Task { @MainActor [weak self] in + await self?.resolveSetupState(for: providerId, expectedRevision: revision) + } + } + } + + func resolveSetupState(for providerId: String, expectedRevision: Int) async { + let setupState = await registry.refreshSetupState(for: providerId) + guard !Task.isCancelled, + isEnabled(providerId), + lifecycleRevisions[providerId, default: 0] == expectedRevision + else { + return + } + + if let setupState { + setState(setupState, for: providerId) + refreshDerived() + return + } + + guard registry.isConfigured(providerId) else { + setState(.unconfigured, for: providerId) + refreshDerived() + return + } + + setState(.loading, for: providerId) + refreshDerived() + startAutoRefresh(for: providerId) + performFetch(for: providerId) + } + + func invalidateProviderWork(for providerId: String) { + lifecycleRevisions[providerId, default: 0] += 1 + stopAutoRefresh(for: providerId) + fetchTasks[providerId]?.cancel() + fetchTasks[providerId] = nil + setupTasks[providerId]?.cancel() + setupTasks[providerId] = nil + setRefreshing(false, for: providerId) + setRefreshError(nil, for: providerId) + } + + func isReadyToFetch(_ providerId: String) -> Bool { + isEnabled(providerId) && registry.isConfigured(providerId) + } + + /// Copy-write-back forces @Observable's setter to fire — dictionary subscript + /// mutations only invoke the getter, so the UI would never see the change. + func setState(_ state: ProviderState, for providerId: String) { + var copy = providerStates + copy[providerId] = state + providerStates = copy + } + + func refreshDerived() { + let byId = Dictionary( + uniqueKeysWithValues: registry.registeredProviders.map { ($0.id, $0) } + ) + let ids = orderedProviderIds + .compactMap { byId[$0] } + .filter { info in + isEnabled(info.id) && Self.isConfiguredState(providerStates[info.id]) + } + .map(\.id) + configuredProviderIds = ids + hasAnyConfiguredProvider = !ids.isEmpty + log("refreshDerived: configuredProviderIds=\(ids) hasAny=\(hasAnyConfiguredProvider)") + } + + func log(_ message: @autoclosure () -> String) { + FileHandle.standardError.write(Data("[QuotaViewModel] \(message())\n".utf8)) + } +} diff --git a/Sources/App/QuotaViewModel+Presentation.swift b/Sources/App/QuotaViewModel+Presentation.swift new file mode 100644 index 0000000..d9282a6 --- /dev/null +++ b/Sources/App/QuotaViewModel+Presentation.swift @@ -0,0 +1,51 @@ +import Core +import SwiftUI + +extension QuotaViewModel { + func providerInfo(for providerId: String) -> ProviderInfo? { + registry.registeredProviders.first { $0.id == providerId } + } + + func isCollapsed(_ providerId: String) -> Bool { + _ = collapseStateRevision + return Self.resolvedCollapseState( + providerId: providerId, + topProviderId: configuredProviderIds.first, + savedState: ProviderCollapseState.collapsedState(for: providerId) + ) + } + + func toggleCollapsed(_ providerId: String) { + ProviderCollapseState.setCollapsed(!isCollapsed(providerId), for: providerId) + collapseStateRevision += 1 + } + + static func resolvedCollapseState( + providerId: String, + topProviderId: String?, + savedState: Bool? + ) -> Bool { + savedState ?? (providerId != topProviderId) + } + + func moveProvider(from source: IndexSet, to destination: Int) { + var ids = orderedProviderIds + ids.move(fromOffsets: source, toOffset: destination) + ProviderOrder.setOrder(ids) + orderedProviderIds = ids + refreshDerived() + } + + func persistOrder(_ ids: [String]) { + ProviderOrder.setOrder(ids) + orderedProviderIds = ids + refreshDerived() + } + + func recomputeOrderedProviderIds() { + let sortedByName = registry.registeredProviders.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + orderedProviderIds = ProviderOrder.effectiveOrder(for: sortedByName.map(\.id)) + } +} diff --git a/Sources/App/QuotaViewModel+Results.swift b/Sources/App/QuotaViewModel+Results.swift new file mode 100644 index 0000000..4a3dd28 --- /dev/null +++ b/Sources/App/QuotaViewModel+Results.swift @@ -0,0 +1,56 @@ +import Core + +extension QuotaViewModel { + func setRefreshing(_ refreshing: Bool, for providerId: String) { + var copy = isRefreshing + copy[providerId] = refreshing + isRefreshing = copy + } + + func setRefreshError(_ message: String?, for providerId: String) { + var copy = refreshErrors + if let message { + copy[providerId] = message + } else { + copy.removeValue(forKey: providerId) + } + refreshErrors = copy + } + + func applyResults( + _ results: [String: Result], + expectedRevisions: [String: Int] + ) { + log("applyResults: got \(results.count) result(s)") + for (id, result) in results { + guard expectedRevisions[id, default: 0] == lifecycleRevisions[id, default: 0], + isReadyToFetch(id) + else { + log("applyResults: provider=\(id) no longer ready, skipping") + continue + } + setRefreshing(false, for: id) + + switch result { + case let .success(quota): + log("applyResults: provider=\(id) success, headline=\(quota.headline)") + setRefreshError(nil, for: id) + setState(.loaded(quota), for: id) + case let .failure(error): + log("applyResults: provider=\(id) failed: \(error.localizedDescription)") + if error is KeychainError { + // Key deleted externally — genuine state change, not a refresh failure. + setRefreshError(nil, for: id) + setState(.unconfigured, for: id) + stopAutoRefresh(for: id) + } else if case .loaded = providerStates[id] { + setRefreshError(error.localizedDescription, for: id) + } else { + setRefreshError(nil, for: id) + setState(.error(error.localizedDescription), for: id) + } + } + } + refreshDerived() + } +} diff --git a/Sources/App/QuotaViewModel+Setup.swift b/Sources/App/QuotaViewModel+Setup.swift new file mode 100644 index 0000000..4e5d918 --- /dev/null +++ b/Sources/App/QuotaViewModel+Setup.swift @@ -0,0 +1,63 @@ +import Core + +extension QuotaViewModel { + /// Returns `true` when the provider's helper can be installed right now. + func canInstallHelper(for providerId: String) -> Bool { + guard isEnabled(providerId) else { return false } + return registry.canInstallHelper(for: providerId) + } + + func credentialImportActionTitle(for providerId: String) -> String? { + guard isEnabled(providerId) else { return nil } + return registry.credentialImportActionTitle(for: providerId) + } + + func installHelper(for providerId: String) async { + guard isEnabled(providerId) else { return } + log("installHelper: provider=\(providerId)") + setState(.loading, for: providerId) + refreshDerived() + do { + try await registry.installHelper(for: providerId) + log("installHelper: provider=\(providerId) success") + startEnabledProvider(for: providerId) + } catch { + log("installHelper: provider=\(providerId) failed: \(error.localizedDescription)") + setState(.error(error.localizedDescription), for: providerId) + refreshDerived() + } + } + + func removeHelper(for providerId: String) async { + guard isEnabled(providerId) else { return } + log("removeHelper: provider=\(providerId)") + setState(.loading, for: providerId) + refreshDerived() + do { + try await registry.removeHelper(for: providerId) + log("removeHelper: provider=\(providerId) success") + invalidateProviderWork(for: providerId) + startEnabledProvider(for: providerId) + } catch { + log("removeHelper: provider=\(providerId) failed: \(error.localizedDescription)") + setState(.error(error.localizedDescription), for: providerId) + } + refreshDerived() + } + + func importCredentials(for providerId: String) async { + guard isEnabled(providerId) else { return } + log("importCredentials: provider=\(providerId)") + setState(.loading, for: providerId) + refreshDerived() + do { + try await registry.importCredentials(for: providerId) + log("importCredentials: provider=\(providerId) success") + startEnabledProvider(for: providerId) + } catch { + log("importCredentials: provider=\(providerId) failed: \(error.localizedDescription)") + setState(.error(error.localizedDescription), for: providerId) + refreshDerived() + } + } +} diff --git a/Sources/App/QuotaViewModel.swift b/Sources/App/QuotaViewModel.swift index d6cb018..005c2dc 100644 --- a/Sources/App/QuotaViewModel.swift +++ b/Sources/App/QuotaViewModel.swift @@ -8,37 +8,45 @@ final class QuotaViewModel { // MARK: - Configuration private let keychain: Keychain - private let registry: ProviderRegistry - private let refreshInterval: TimeInterval = 300 + let registry: ProviderRegistry + let refreshInterval: TimeInterval = 300 // MARK: - State /// Must be assigned as a whole value — dictionary subscript mutation /// does not trigger @Observable's setter. - private(set) var providerStates: [String: ProviderState] = [:] + var providerStates: [String: ProviderState] = [:] /// Reassigned as a whole value so @Observable notifies observers — /// `ProviderOrder` and `registry` are not observable, so a computed property /// would not trigger re-renders. - private(set) var orderedProviderIds: [String] = [] + var orderedProviderIds: [String] = [] - private(set) var configuredProviderIds: [String] = [] + var enabledProviderIds: Set = [] - private(set) var hasAnyConfiguredProvider: Bool = false + var configuredProviderIds: [String] = [] + + var hasAnyConfiguredProvider: Bool = false /// Changing this token tells SwiftUI to re-resolve the UserDefaults-backed /// collapse values that live in Core. - private var collapseStateRevision = 0 + var collapseStateRevision = 0 // MARK: - Quiet refresh - private(set) var isRefreshing: [String: Bool] = [:] + var isRefreshing: [String: Bool] = [:] - private(set) var refreshErrors: [String: String] = [:] + var refreshErrors: [String: String] = [:] // MARK: - Auto-refresh - private var refreshLoops: [String: Task] = [:] + var refreshLoops: [String: Task] = [:] + + var fetchTasks: [String: Task] = [:] + + var setupTasks: [String: Task] = [:] + + var lifecycleRevisions: [String: Int] = [:] // MARK: - Init @@ -49,30 +57,33 @@ final class QuotaViewModel { self.keychain = keychain self.registry = registry + var enabledIds: Set = [] + var configuredIds: Set = [] for info in registry.registeredProviders { + guard registry.isEnabled(info.id) else { + setState(.unconfigured, for: info.id) + continue + } + enabledIds.insert(info.id) + let configured = registry.isConfigured(info.id) log("init: provider=\(info.id) configured=\(configured)") - - switch info.authShape { - case .apiKey: - setState(configured ? .loading : .unconfigured, for: info.id) - if configured { - startAutoRefresh(for: info.id) - } - case .apiKeyFree: - if configured { - setState(.loading, for: info.id) - startAutoRefresh(for: info.id) - } else { - setState(.unconfigured, for: info.id) - } + setState(configured ? .loading : .unconfigured, for: info.id) + if configured { + configuredIds.insert(info.id) } } + enabledProviderIds = enabledIds + for providerId in configuredIds { + startAutoRefresh(for: providerId) + } recomputeOrderedProviderIds() refreshDerived() fetchAllQuotas() - refreshAllSetupStates() + for info in registry.registeredProviders where info.authShape == .apiKeyFree { + startEnabledProvider(for: info.id) + } } // MARK: - Derived properties — public @@ -91,15 +102,14 @@ final class QuotaViewModel { /// `isConfiguredState`) so "what counts as configured" is defined in one place. var configuredProvidersOrdered: [ProviderInfo] { registeredProvidersOrdered.filter { info in - guard let state = providerStates[info.id] else { return false } - return Self.isConfiguredState(state) + isEnabled(info.id) && Self.isConfiguredState(providerStates[info.id]) } } /// Must stay in sync with the states assigned by `init` and `setState(_:for:)`. - private static func isConfiguredState(_ state: ProviderState) -> Bool { + static func isConfiguredState(_ state: ProviderState?) -> Bool { switch state { - case .unconfigured, .setup: + case .none, .unconfigured, .setup: false case .loading, .loaded, .error: true @@ -111,18 +121,15 @@ final class QuotaViewModel { func saveKey(_ key: String, for providerId: String) throws { try keychain.save(key, for: providerId) log("saveKey: provider=\(providerId)") - setState(.loading, for: providerId) - refreshDerived() - startAutoRefresh(for: providerId) - performFetch(for: providerId) + setProviderEnabled(true, for: providerId) } func deleteKey(for providerId: String) throws { try keychain.delete(for: providerId) log("deleteKey: provider=\(providerId)") + invalidateProviderWork(for: providerId) setState(.unconfigured, for: providerId) refreshDerived() - stopAutoRefresh(for: providerId) } // MARK: - Base-URL override @@ -135,43 +142,16 @@ final class QuotaViewModel { guard !registry.isAPIKeyFree(providerId) else { return } try ProviderOverrides.setBaseURL(url, for: providerId) log("saveOverrideURL: provider=\(providerId) url=\(url?.absoluteString ?? "nil")") - if registry.isConfigured(providerId) { + if isEnabled(providerId), registry.isConfigured(providerId) { performFetch(for: providerId) } } - // MARK: - Setup state refresh - - private func refreshAllSetupStates() { - Task { - let states = await registry.refreshSetupStates() - let providers = registry.registeredProviders.filter { - $0.authShape == .apiKeyFree - } - for provider in providers { - if let state = states[provider.id] { - setState(state, for: provider.id) - continue - } - guard registry.isConfigured(provider.id) else { continue } - switch providerStates[provider.id] { - case .loading, .loaded: - continue - default: - setState(.loading, for: provider.id) - startAutoRefresh(for: provider.id) - performFetch(for: provider.id) - } - } - refreshDerived() - } - } - // MARK: - Fetch func fetchQuota(for providerId: String) { - guard registry.isConfigured(providerId) else { - log("fetchQuota: provider=\(providerId) not configured, skipping") + guard isReadyToFetch(providerId) else { + log("fetchQuota: provider=\(providerId) is not ready, skipping") return } if case .loading = providerStates[providerId] { @@ -191,8 +171,8 @@ final class QuotaViewModel { /// the initial fetch still call `fetchQuota(for:)` directly — proactive /// spawn is manual-only. func manualRefresh(for providerId: String) { - guard registry.isConfigured(providerId) else { - log("manualRefresh: provider=\(providerId) not configured, skipping") + guard isReadyToFetch(providerId) else { + log("manualRefresh: provider=\(providerId) is not ready, skipping") return } if case .loading = providerStates[providerId] { @@ -235,13 +215,16 @@ final class QuotaViewModel { func fetchAllQuotas() { log("fetchAllQuotas: starting") - Task { + let revisions = lifecycleRevisions + Task { @MainActor [weak self] in + guard let self else { return } let results = await registry.fetchAll() - applyResults(results) + applyResults(results, expectedRevisions: revisions) } } - private func performFetch(for providerId: String) { + func performFetch(for providerId: String) { + guard isReadyToFetch(providerId) else { return } log("performFetch: provider=\(providerId)") switch providerStates[providerId] { case .loaded, .error: @@ -250,238 +233,19 @@ final class QuotaViewModel { setState(.loading, for: providerId) refreshDerived() } - Task { - let results = await registry.fetchAll() - applyResults(results) - } - } - - // MARK: - Auto-refresh loop - - private func startAutoRefresh(for providerId: String) { - stopAutoRefresh(for: providerId) - let interval = refreshInterval - refreshLoops[providerId] = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(interval)) - guard !Task.isCancelled else { break } - await MainActor.run { [weak self] in - self?.fetchQuota(for: providerId) - } - } - } - } - - private func stopAutoRefresh(for providerId: String) { - refreshLoops[providerId]?.cancel() - refreshLoops[providerId] = nil - } - - // MARK: - Helpers - - /// Copy-write-back forces @Observable's setter to fire — dictionary subscript - /// mutations only invoke the getter, so the UI would never see the change. - private func setState(_ state: ProviderState, for providerId: String) { - var copy = providerStates - copy[providerId] = state - providerStates = copy - } - - private func refreshDerived() { - let byId = Dictionary( - uniqueKeysWithValues: registry.registeredProviders.map { ($0.id, $0) } - ) - let ids = orderedProviderIds - .compactMap { byId[$0] } - .filter { info in - guard let state = providerStates[info.id] else { return false } - return Self.isConfiguredState(state) - } - .map(\.id) - configuredProviderIds = ids - hasAnyConfiguredProvider = !ids.isEmpty - log("refreshDerived: configuredProviderIds=\(ids) hasAny=\(hasAnyConfiguredProvider)") - } - - // MARK: - Diagnostic logging - - private func log(_ message: @autoclosure () -> String) { - FileHandle.standardError.write(Data("[QuotaViewModel] \(message())\n".utf8)) - } -} - -// MARK: - Setup actions - -extension QuotaViewModel { - /// Returns `true` when the provider's helper can be installed right now. - func canInstallHelper(for providerId: String) -> Bool { - registry.canInstallHelper(for: providerId) - } - - func credentialImportActionTitle(for providerId: String) -> String? { - registry.credentialImportActionTitle(for: providerId) - } - - func installHelper(for providerId: String) async { - log("installHelper: provider=\(providerId)") - setState(.loading, for: providerId) - refreshDerived() - do { - try await registry.installHelper(for: providerId) - log("installHelper: provider=\(providerId) success") - startAutoRefresh(for: providerId) - performFetch(for: providerId) - } catch { - log("installHelper: provider=\(providerId) failed: \(error.localizedDescription)") - setState(.error(error.localizedDescription), for: providerId) - refreshDerived() - } - } - - func removeHelper(for providerId: String) async { - log("removeHelper: provider=\(providerId)") - setState(.loading, for: providerId) - refreshDerived() - do { - try await registry.removeHelper(for: providerId) - log("removeHelper: provider=\(providerId) success") - stopAutoRefresh(for: providerId) - let states = await registry.refreshSetupStates() - if let newState = states[providerId] { - setState(newState, for: providerId) - } else { - setState(.setup(String(localized: "Helper removed")), for: providerId) - } - } catch { - log("removeHelper: provider=\(providerId) failed: \(error.localizedDescription)") - setState(.error(error.localizedDescription), for: providerId) - } - refreshDerived() - } - - func importCredentials(for providerId: String) async { - log("importCredentials: provider=\(providerId)") - setState(.loading, for: providerId) - refreshDerived() - do { - try await registry.importCredentials(for: providerId) - log("importCredentials: provider=\(providerId) success") - startAutoRefresh(for: providerId) - performFetch(for: providerId) - } catch { - log("importCredentials: provider=\(providerId) failed: \(error.localizedDescription)") - setState(.error(error.localizedDescription), for: providerId) - refreshDerived() - } - } -} - -// MARK: - Provider cards - -extension QuotaViewModel { - func providerInfo(for providerId: String) -> ProviderInfo? { - registry.registeredProviders.first { $0.id == providerId } - } - - func isCollapsed(_ providerId: String) -> Bool { - _ = collapseStateRevision - return Self.resolvedCollapseState( - providerId: providerId, - topProviderId: configuredProviderIds.first, - savedState: ProviderCollapseState.collapsedState(for: providerId) - ) - } - - func toggleCollapsed(_ providerId: String) { - ProviderCollapseState.setCollapsed(!isCollapsed(providerId), for: providerId) - collapseStateRevision += 1 - } - - static func resolvedCollapseState( - providerId: String, - topProviderId: String?, - savedState: Bool? - ) -> Bool { - savedState ?? (providerId != topProviderId) - } -} - -// MARK: - Provider ordering - -extension QuotaViewModel { - func moveProvider(from source: IndexSet, to destination: Int) { - var ids = orderedProviderIds - ids.move(fromOffsets: source, toOffset: destination) - ProviderOrder.setOrder(ids) - orderedProviderIds = ids - refreshDerived() - } - - func persistOrder(_ ids: [String]) { - ProviderOrder.setOrder(ids) - orderedProviderIds = ids - refreshDerived() - } - - /// Display-name ascending is the App-layer fallback because Core's - /// `ProviderOrder.effectiveOrder(for:)` is name-agnostic. - private func recomputeOrderedProviderIds() { - let sortedByName = registry.registeredProviders.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - orderedProviderIds = ProviderOrder.effectiveOrder(for: sortedByName.map(\.id)) - } -} - -// MARK: - Quiet-refresh state mutation + result processing - -private extension QuotaViewModel { - func setRefreshing(_ refreshing: Bool, for providerId: String) { - var copy = isRefreshing - copy[providerId] = refreshing - isRefreshing = copy - } - - func setRefreshError(_ message: String?, for providerId: String) { - var copy = refreshErrors - if let message { - copy[providerId] = message - } else { - copy.removeValue(forKey: providerId) - } - refreshErrors = copy - } - - func applyResults(_ results: [String: Result]) { - log("applyResults: got \(results.count) result(s)") - for (id, result) in results { - guard registry.isConfigured(id) else { - log("applyResults: provider=\(id) no longer configured, skipping") - continue + let revision = lifecycleRevisions[providerId, default: 0] + fetchTasks[providerId]?.cancel() + fetchTasks[providerId] = Task { @MainActor [weak self] in + guard let self, + let result = await registry.fetchQuota(for: providerId), + !Task.isCancelled + else { + return } - setRefreshing(false, for: id) - - switch result { - case let .success(quota): - log("applyResults: provider=\(id) success, headline=\(quota.headline)") - setRefreshError(nil, for: id) - setState(.loaded(quota), for: id) - case let .failure(error): - log("applyResults: provider=\(id) failed: \(error.localizedDescription)") - if error is KeychainError { - // Key deleted externally — genuine state change, not a refresh failure. - setRefreshError(nil, for: id) - setState(.unconfigured, for: id) - stopAutoRefresh(for: id) - } else if case .loaded = providerStates[id] { - setRefreshError(error.localizedDescription, for: id) - } else { - setRefreshError(nil, for: id) - setState(.error(error.localizedDescription), for: id) - } + applyResults([providerId: result], expectedRevisions: [providerId: revision]) + if lifecycleRevisions[providerId, default: 0] == revision { + fetchTasks[providerId] = nil } } - refreshDerived() } } diff --git a/Sources/App/Resources/Localizable.xcstrings b/Sources/App/Resources/Localizable.xcstrings index 93c6306..c87c058 100644 --- a/Sources/App/Resources/Localizable.xcstrings +++ b/Sources/App/Resources/Localizable.xcstrings @@ -1494,6 +1494,42 @@ "es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Error" } } } }, + "Disabled" : { + "extractionState" : "manual", + "localizations" : { + "de-DE" : { "stringUnit" : { "state" : "translated", "value" : "Deaktiviert" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Disabled" } }, + "es-ES" : { "stringUnit" : { "state" : "translated", "value" : "Desactivado" } }, + "es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Desactivado" } } + } + }, + "Enable %@" : { + "extractionState" : "manual", + "localizations" : { + "de-DE" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktivieren" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enable %@" } }, + "es-ES" : { "stringUnit" : { "state" : "translated", "value" : "Activar %@" } }, + "es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Activar %@" } } + } + }, + "Enable this provider to view setup options." : { + "extractionState" : "manual", + "localizations" : { + "de-DE" : { "stringUnit" : { "state" : "translated", "value" : "Aktiviere diesen Anbieter, um Einrichtungsoptionen anzuzeigen." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enable this provider to view setup options." } }, + "es-ES" : { "stringUnit" : { "state" : "translated", "value" : "Activa este proveedor para ver las opciones de configuración." } }, + "es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Activa este proveedor para ver las opciones de configuración." } } + } + }, + "Enabled" : { + "extractionState" : "manual", + "localizations" : { + "de-DE" : { "stringUnit" : { "state" : "translated", "value" : "Aktiviert" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enabled" } }, + "es-ES" : { "stringUnit" : { "state" : "translated", "value" : "Activado" } }, + "es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Activado" } } + } + }, "Menu bar icon" : { "extractionState" : "manual", "localizations" : { diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 0d54b76..3a538fd 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -34,61 +34,108 @@ struct SettingsView: View { private var providersTab: some View { SettingsScrollColumn { ForEach(viewModel.registeredProvidersOrdered) { provider in - let state = viewModel.providerStates[provider.id] ?? .unconfigured - SettingsCard { - switch provider.authShape { - case .apiKey: - ProviderSettingsRow( - provider: provider, - state: state, - overrideURL: viewModel.overrideURL(for: provider.id), - onSaveKey: { key in - try viewModel.saveKey(key, for: provider.id) - }, - onClearKey: { - try viewModel.deleteKey(for: provider.id) - }, - onSaveOverride: { url in - try viewModel.saveOverrideURL(url, for: provider.id) - } - ) - case .apiKeyFree: - APIKeyFreeSettingsRow( - provider: provider, - state: state, - canInstall: viewModel.canInstallHelper(for: provider.id), - credentialImportActionTitle: viewModel.credentialImportActionTitle(for: provider.id), - onInstall: { - Task { await viewModel.installHelper(for: provider.id) } - }, - onRemove: { - Task { await viewModel.removeHelper(for: provider.id) } - }, - onImportCredentials: { - Task { await viewModel.importCredentials(for: provider.id) } - } - ) - } - - if let disclaimer = provider.disclaimer { - Divider() - Label(disclaimer, systemImage: "info.circle") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } + providerCard(for: provider) } } .navigationTitle(String(localized: "Providers")) } + + @ViewBuilder + private func providerCard(for provider: ProviderInfo) -> some View { + let state = viewModel.providerStates[provider.id] ?? .unconfigured + let isEnabled = viewModel.isEnabled(provider.id) + SettingsCard { + providerSettingsRow(for: provider, state: state, isEnabled: isEnabled) + providerDisclaimer(for: provider) + } + } + + @ViewBuilder + private func providerSettingsRow( + for provider: ProviderInfo, + state: ProviderState, + isEnabled: Bool + ) -> some View { + switch provider.authShape { + case .apiKey: + apiKeySettingsRow(for: provider, state: state, isEnabled: isEnabled) + case .apiKeyFree: + apiKeyFreeSettingsRow(for: provider, state: state, isEnabled: isEnabled) + } + } + + private func apiKeySettingsRow( + for provider: ProviderInfo, + state: ProviderState, + isEnabled: Bool + ) -> ProviderSettingsRow { + ProviderSettingsRow( + provider: provider, + state: state, + isEnabled: isEnabled, + overrideURL: viewModel.overrideURL(for: provider.id), + onEnabledChange: { enabled in + viewModel.setProviderEnabled(enabled, for: provider.id) + }, + onSaveKey: { key in + try viewModel.saveKey(key, for: provider.id) + }, + onClearKey: { + try viewModel.deleteKey(for: provider.id) + }, + onSaveOverride: { url in + try viewModel.saveOverrideURL(url, for: provider.id) + } + ) + } + + private func apiKeyFreeSettingsRow( + for provider: ProviderInfo, + state: ProviderState, + isEnabled: Bool + ) -> APIKeyFreeSettingsRow { + APIKeyFreeSettingsRow( + provider: provider, + state: state, + isEnabled: isEnabled, + canInstall: isEnabled && viewModel.canInstallHelper(for: provider.id), + credentialImportActionTitle: isEnabled + ? viewModel.credentialImportActionTitle(for: provider.id) + : nil, + onEnabledChange: { enabled in + viewModel.setProviderEnabled(enabled, for: provider.id) + }, + onInstall: { + Task { await viewModel.installHelper(for: provider.id) } + }, + onRemove: { + Task { await viewModel.removeHelper(for: provider.id) } + }, + onImportCredentials: { + Task { await viewModel.importCredentials(for: provider.id) } + } + ) + } + + @ViewBuilder + private func providerDisclaimer(for provider: ProviderInfo) -> some View { + if let disclaimer = provider.disclaimer { + Divider() + Label(disclaimer, systemImage: "info.circle") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } } @MainActor private struct ProviderSettingsRow: View { let provider: ProviderInfo let state: ProviderState + let isEnabled: Bool let overrideURL: URL? + let onEnabledChange: @MainActor @Sendable (Bool) -> Void let onSaveKey: (String) throws -> Void let onClearKey: () throws -> Void let onSaveOverride: (URL?) throws -> Void @@ -103,11 +150,13 @@ private struct ProviderSettingsRow: View { VStack(alignment: .leading, spacing: 12) { SettingsCardHeader( provider: provider, - status: ProviderStatusPresentation.apiKey(state), + status: isEnabled ? ProviderStatusPresentation.apiKey(state) : .disabled, + isEnabled: isEnabled, + onEnabledChange: onEnabledChange, supplementaryLabel: overrideURL == nil ? nil : String(localized: "custom URL") ) Divider() - if isConfigured { + if isEnabled, isConfigured { configuredContent } else { keyEntry @@ -280,121 +329,3 @@ private struct ProviderSettingsRow: View { overrideErrorMessage = nil } } - -@MainActor -private struct APIKeyFreeSettingsRow: View { - let provider: ProviderInfo - let state: ProviderState - let canInstall: Bool - let credentialImportActionTitle: String? - let onInstall: () -> Void - let onRemove: () -> Void - let onImportCredentials: () -> Void - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - SettingsCardHeader( - provider: provider, - status: ProviderStatusPresentation.apiKeyFree(state) - ) - - Divider() - - switch state { - case .loading: - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text(String(localized: "Working…")) - .font(.caption) - .foregroundColor(.secondary) - } - case let .setup(reason): - VStack(alignment: .leading, spacing: 8) { - if canInstall { - installPromptView(reason: reason) - } else { - setupReasonView(reason: reason) - } - credentialImportButton - } - case .loaded: - removeHelperView - case let .error(message): - VStack(alignment: .leading, spacing: 8) { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(ProviderVisualStyle.tierColor(.critical, scheme: colorScheme)) - credentialImportButton - } - case .unconfigured: - Text(String(localized: "Not configured")) - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - private func installPromptView(reason: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - setupReasonView(reason: reason) - - Text(String(localized: "Filbert reads your Claude Code usage by hooking into its status line.")) - .font(.caption) - .foregroundColor(.secondary) - Text(String(localized: "This adds a small helper script to ~/.claude/.")) - .font(.caption) - .foregroundColor(.secondary) - - Button(String(localized: "Install Helper")) { - onInstall() - } - .buttonStyle(.borderedProminent) - } - } - - private func setupReasonView(reason: String) -> some View { - HStack(spacing: 6) { - if let setupHelp = provider.setupHelp { - Button { - NSWorkspace.shared.open(setupHelp.url) - } label: { - Label(setupHelp.linkLabel, systemImage: "arrow.up.right") - } - .font(.caption) - .buttonStyle(.link) - .layoutPriority(1) - } - - Text(reason) - .font(.caption) - .foregroundColor(.secondary) - - Spacer(minLength: 0) - } - } - - @ViewBuilder - private var credentialImportButton: some View { - if let credentialImportActionTitle { - Button(credentialImportActionTitle) { - onImportCredentials() - } - .buttonStyle(.bordered) - } - } - - private var removeHelperView: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "Helper installed and active.")) - .font(.caption) - .foregroundColor(.secondary) - - Button(String(localized: "Remove Helper"), role: .destructive) { - onRemove() - } - } - } -} diff --git a/Sources/App/SettingsVisualComponents.swift b/Sources/App/SettingsVisualComponents.swift index f17a76e..27a44f3 100644 --- a/Sources/App/SettingsVisualComponents.swift +++ b/Sources/App/SettingsVisualComponents.swift @@ -104,6 +104,8 @@ struct SettingsCard: View { struct SettingsCardHeader: View { let provider: ProviderInfo let status: ProviderStatusPresentation + let isEnabled: Bool + let onEnabledChange: @MainActor @Sendable (Bool) -> Void var supplementaryLabel: String? var body: some View { @@ -121,6 +123,30 @@ struct SettingsCardHeader: View { VStack(alignment: .trailing, spacing: 4) { ProviderStatusPill(status: status) + Toggle( + String(localized: "Enabled"), + isOn: Binding( + get: { isEnabled }, + set: { enabled in + onEnabledChange(enabled) + } + ) + ) + .toggleStyle(.switch) + .controlSize(.small) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel( + String.localizedStringWithFormat( + String(localized: "Enable %@"), + provider.displayName + ) + ) + .accessibilityValue( + isEnabled + ? String(localized: "Enabled") + : String(localized: "Disabled") + ) if let supplementaryLabel { Text(supplementaryLabel) .font(.caption2.monospaced()) @@ -231,6 +257,12 @@ struct ProviderStatusPresentation { } } + static let disabled = Self( + label: String(localized: "Disabled"), + symbolName: "pause.circle.fill", + role: .neutral + ) + private static let error = Self( label: String(localized: "Error"), symbolName: "exclamationmark.triangle.fill", diff --git a/Sources/Core/Keychain.swift b/Sources/Core/Keychain.swift index a8a2ffd..4d24efd 100644 --- a/Sources/Core/Keychain.swift +++ b/Sources/Core/Keychain.swift @@ -42,7 +42,7 @@ public final class Keychain: @unchecked Sendable { ) } - init( + public init( storage: any KeychainStorage, service: String ) { diff --git a/Sources/Core/ProviderEnablement.swift b/Sources/Core/ProviderEnablement.swift new file mode 100644 index 0000000..449a280 --- /dev/null +++ b/Sources/Core/ProviderEnablement.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Enablement is user preference state, not a secret, so it belongs in +/// `UserDefaults` alongside provider ordering and collapse state. +public enum ProviderEnablement { + // `nonisolated(unsafe)`: production sets this once to `.standard` and never + // mutates it; the only writer is the test-injection API, and XCTest runs + // tests serially. `UserDefaults` itself is thread-safe for reads/writes. + private nonisolated(unsafe) static var defaults: UserDefaults = .standard + + public static func isEnabled( + for providerId: String, + authShape: ProviderAuth.Shape, + keychain: Keychain = .shared + ) -> Bool { + if let enabled = savedEnabled(for: providerId) { + return enabled + } + + let initialValue = switch authShape { + case .apiKey: + (try? keychain.load(for: providerId)) != nil + case .apiKeyFree: + false + } + setEnabled(initialValue, for: providerId) + return initialValue + } + + public static func savedEnabled(for providerId: String) -> Bool? { + savedValues()[providerId] + } + + public static func setEnabled(_ enabled: Bool, for providerId: String) { + var values = savedValues() + values[providerId] = enabled + defaults.set(values, forKey: storageKey) + } + + /// Test-only escape hatch: swaps the backing store. + public static func setUserDefaults(_ defaults: UserDefaults) { + Self.defaults = defaults + } + + private static func savedValues() -> [String: Bool] { + defaults.dictionary(forKey: storageKey) as? [String: Bool] ?? [:] + } + + private static let storageKey = "provider-enablement" +} diff --git a/Sources/Core/ProviderRegistry.swift b/Sources/Core/ProviderRegistry.swift index 9d7da78..19e8350 100644 --- a/Sources/Core/ProviderRegistry.swift +++ b/Sources/Core/ProviderRegistry.swift @@ -7,9 +7,11 @@ import Foundation @MainActor public final class ProviderRegistry { private var providers: [String: any AIProvider] = [:] - private let keychain = Keychain.shared + private let keychain: Keychain - public init() {} + public init(keychain: Keychain = .shared) { + self.keychain = keychain + } public func register(_ provider: any AIProvider) { let id = type(of: provider).providerId @@ -32,11 +34,25 @@ public final class ProviderRegistry { } } + public func isEnabled(_ providerId: String) -> Bool { + guard let provider = providers[providerId] else { return false } + return ProviderEnablement.isEnabled( + for: providerId, + authShape: type(of: provider).authShape, + keychain: keychain + ) + } + + public func setEnabled(_ enabled: Bool, for providerId: String) { + guard providers[providerId] != nil else { return } + ProviderEnablement.setEnabled(enabled, for: providerId) + } + /// For `.apiKey` providers this checks the Keychain; for `.apiKeyFree` /// providers it delegates to the provider's own `isConfigured()` — the /// provider owns what "configured" means for its auth shape. public func isConfigured(_ providerId: String) -> Bool { - guard let provider = providers[providerId] else { return false } + guard isEnabled(providerId), let provider = providers[providerId] else { return false } let shape = type(of: provider).authShape switch shape { case .apiKey: @@ -47,37 +63,14 @@ public final class ProviderRegistry { } public func fetchAll() async -> [String: Result] { - // Snapshot before crossing into the TaskGroup so child tasks never - // touch MainActor-isolated state. - let snapshot = providers - let keychain = keychain + let requests = providers.keys.compactMap(fetchRequest(for:)) return await withTaskGroup( of: (String, Result).self ) { group in - for (id, provider) in snapshot { - let providerId = id - let shape = type(of: provider).authShape + for request in requests { group.addTask { - do { - let auth: ProviderAuth - switch shape { - case .apiKey: - let apiKey = try keychain.load(for: providerId) - auth = .apiKey(apiKey) - case .apiKeyFree: - auth = .apiKeyFree - } - let baseURL = ProviderOverrides.baseURL(for: providerId) - ?? type(of: provider).baseURL - let quota = try await provider.fetchQuota( - auth: auth, - baseURL: baseURL - ) - return (providerId, .success(quota)) - } catch { - return (providerId, .failure(error)) - } + await Self.fetch(request) } } @@ -89,24 +82,33 @@ public final class ProviderRegistry { } } + public func fetchQuota( + for providerId: String + ) async -> Result? { + guard let request = fetchRequest(for: providerId) else { return nil } + let (_, result) = await Self.fetch(request) + return result + } + // MARK: - Setup state /// Fires `currentSetupState()` on every registered `.apiKeyFree` provider /// concurrently. `.apiKey` providers are not called — their setup state is - /// always `nil`. The view model calls this at launch and after - /// install/uninstall actions to re-sync without blocking the main actor. + /// always `nil`; disabled providers are excluded before their state is read. public func refreshSetupStates() async -> [String: ProviderState] { - // Snapshot before crossing into the TaskGroup so child tasks never - // touch MainActor-isolated state. - let snapshot = providers + let snapshot = providers.compactMap { providerId, provider -> (String, any AIProvider)? in + guard type(of: provider).authShape == .apiKeyFree, + isEnabled(providerId) + else { + return nil + } + return (providerId, provider) + } return await withTaskGroup( of: (String, ProviderState?).self ) { group in - for (id, provider) in snapshot { - let providerId = id - let shape = type(of: provider).authShape - guard shape == .apiKeyFree else { continue } + for (providerId, provider) in snapshot { group.addTask { let state = await provider.currentSetupState() return (providerId, state) @@ -123,6 +125,16 @@ public final class ProviderRegistry { } } + public func refreshSetupState(for providerId: String) async -> ProviderState? { + guard isEnabled(providerId), + let provider = providers[providerId], + type(of: provider).authShape == .apiKeyFree + else { + return nil + } + return await provider.currentSetupState() + } + // MARK: - Helper management /// The popover uses this to suppress the "Clear Key" button. @@ -133,14 +145,14 @@ public final class ProviderRegistry { /// `.apiKey` providers always return `false` — they have no helper. public func canInstallHelper(for providerId: String) -> Bool { - guard let provider = providers[providerId] else { return false } + guard isEnabled(providerId), let provider = providers[providerId] else { return false } return provider.canInstallHelper() } /// Throws when the provider is not registered or does not support helper /// installation. public func installHelper(for providerId: String) async throws { - guard let provider = providers[providerId] else { + guard isEnabled(providerId), let provider = providers[providerId] else { throw ProviderSetupError.notSupported } try await provider.installHelper() @@ -149,7 +161,7 @@ public final class ProviderRegistry { /// Throws when the provider is not registered or does not support helper /// removal. public func removeHelper(for providerId: String) async throws { - guard let provider = providers[providerId] else { + guard isEnabled(providerId), let provider = providers[providerId] else { throw ProviderSetupError.notSupported } try await provider.removeHelper() @@ -159,13 +171,14 @@ public final class ProviderRegistry { /// Returns `nil` when the provider does not support credential import. public func credentialImportActionTitle(for providerId: String) -> String? { - providers[providerId].map { type(of: $0).credentialImportActionTitle } ?? nil + guard isEnabled(providerId), let provider = providers[providerId] else { return nil } + return type(of: provider).credentialImportActionTitle } /// Routes an explicit credential import without inspecting a provider ID /// or provider-specific credential shape. public func importCredentials(for providerId: String) async throws { - guard let provider = providers[providerId] else { + guard isEnabled(providerId), let provider = providers[providerId] else { throw ProviderSetupError.notSupported } try await provider.importCredentials() @@ -178,7 +191,7 @@ public final class ProviderRegistry { /// proceeds straight to `fetchQuota`, so non-conforming providers are /// unaffected. public func proactiveRefresh(for providerId: String) async throws { - guard let provider = providers[providerId] else { + guard isEnabled(providerId), let provider = providers[providerId] else { throw ProviderSetupError.notSupported } guard let refreshable = provider as? ProactiveRefreshable else { @@ -187,3 +200,51 @@ public final class ProviderRegistry { try await refreshable.proactiveRefresh() } } + +private extension ProviderRegistry { + struct FetchRequest: Sendable { + let providerId: String + let provider: any AIProvider + let authShape: ProviderAuth.Shape + let keychain: Keychain + + func authentication() throws -> ProviderAuth { + switch authShape { + case .apiKey: + let apiKey = try keychain.load(for: providerId) + return .apiKey(apiKey) + case .apiKeyFree: + return .apiKeyFree + } + } + } + + func fetchRequest(for providerId: String) -> FetchRequest? { + guard isEnabled(providerId), + isConfigured(providerId), + let provider = providers[providerId] + else { + return nil + } + return FetchRequest( + providerId: providerId, + provider: provider, + authShape: type(of: provider).authShape, + keychain: keychain + ) + } + + nonisolated static func fetch( + _ request: FetchRequest + ) async -> (String, Result) { + do { + let auth = try request.authentication() + let baseURL = ProviderOverrides.baseURL(for: request.providerId) + ?? type(of: request.provider).baseURL + let quota = try await request.provider.fetchQuota(auth: auth, baseURL: baseURL) + return (request.providerId, .success(quota)) + } catch { + return (request.providerId, .failure(error)) + } + } +} diff --git a/Tests/AppTests/ConfiguredProvidersOrderedTests.swift b/Tests/AppTests/ConfiguredProvidersOrderedTests.swift index 3cc4a93..f18e725 100644 --- a/Tests/AppTests/ConfiguredProvidersOrderedTests.swift +++ b/Tests/AppTests/ConfiguredProvidersOrderedTests.swift @@ -1,14 +1,33 @@ @testable import App import Core +import Foundation import XCTest @MainActor final class ConfiguredProvidersOrderedTests: XCTestCase { + private let suiteName = "filbert.tests.configured-providers-ordered" + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(.standard) + defaults = nil + super.tearDown() + } + func testConfiguredProvidersOrderedExcludesUnconfiguredProviders() { let registry = ProviderRegistry() registry.register(UnconfiguredAPIKeyProvider()) registry.register(UnconfiguredAPIKeyFreeProvider()) registry.register(ConfiguredAPIKeyFreeProvider()) + ProviderEnablement.setEnabled(true, for: ConfiguredAPIKeyFreeProvider.providerId) let viewModel = QuotaViewModel(registry: registry) @@ -27,6 +46,7 @@ final class ConfiguredProvidersOrderedTests: XCTestCase { let registry = ProviderRegistry() registry.register(UnconfiguredAPIKeyProvider()) registry.register(ConfiguredAPIKeyFreeProvider()) + ProviderEnablement.setEnabled(true, for: ConfiguredAPIKeyFreeProvider.providerId) let viewModel = QuotaViewModel(registry: registry) diff --git a/Tests/AppTests/CredentialImportViewModelTests.swift b/Tests/AppTests/CredentialImportViewModelTests.swift index fd7676e..a3b2eb3 100644 --- a/Tests/AppTests/CredentialImportViewModelTests.swift +++ b/Tests/AppTests/CredentialImportViewModelTests.swift @@ -1,13 +1,32 @@ @testable import App import Core +import Foundation import XCTest @MainActor final class CredentialImportViewModelTests: XCTestCase { + private let suiteName = "filbert.tests.credential-import-view-model" + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(.standard) + defaults = nil + super.tearDown() + } + func testImportActionRoutesThroughViewModelWithoutProviderIDBranch() async { let registry = ProviderRegistry() let provider = ImportingTestProvider() registry.register(provider) + ProviderEnablement.setEnabled(true, for: ImportingTestProvider.providerId) let viewModel = QuotaViewModel(registry: registry) XCTAssertEqual( diff --git a/Tests/AppTests/ProviderEnablementViewModelTests.swift b/Tests/AppTests/ProviderEnablementViewModelTests.swift new file mode 100644 index 0000000..e585c5a --- /dev/null +++ b/Tests/AppTests/ProviderEnablementViewModelTests.swift @@ -0,0 +1,294 @@ +@testable import App +import Core +import Foundation +import XCTest + +@MainActor +final class ProviderEnablementViewModelTests: XCTestCase { + private let suiteName = "filbert.tests.provider-enablement-view-model" + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(.standard) + defaults = nil + super.tearDown() + } + + func testSavingKeyEnablesProviderAfterKeychainSave() throws { + let storage = ViewModelKeychainStorage() + let keychain = Keychain(storage: storage, service: "view-model") + let registry = ProviderRegistry(keychain: keychain) + registry.register(APIKeySpyProvider()) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + + XCTAssertFalse(viewModel.isEnabled(APIKeySpyProvider.providerId)) + + try viewModel.saveKey("saved-key", for: APIKeySpyProvider.providerId) + + XCTAssertTrue(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertEqual(try keychain.load(for: APIKeySpyProvider.providerId), "saved-key") + XCTAssertEqual(ProviderEnablement.savedEnabled(for: APIKeySpyProvider.providerId), true) + } + + func testFailedKeySaveLeavesEnablementUnchanged() { + let storage = ViewModelKeychainStorage() + storage.shouldFailWrites = true + let keychain = Keychain(storage: storage, service: "view-model") + let registry = ProviderRegistry(keychain: keychain) + registry.register(APIKeySpyProvider()) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + + XCTAssertThrowsError( + try viewModel.saveKey("saved-key", for: APIKeySpyProvider.providerId) + ) + XCTAssertFalse(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: APIKeySpyProvider.providerId), false) + } + + func testClearingKeyKeepsProviderEnabled() throws { + let storage = ViewModelKeychainStorage() + let keychain = Keychain(storage: storage, service: "view-model") + let registry = ProviderRegistry(keychain: keychain) + registry.register(APIKeySpyProvider()) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + try viewModel.saveKey("saved-key", for: APIKeySpyProvider.providerId) + + try viewModel.deleteKey(for: APIKeySpyProvider.providerId) + + XCTAssertTrue(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: APIKeySpyProvider.providerId), true) + XCTAssertFalse(registry.isConfigured(APIKeySpyProvider.providerId)) + XCTAssertFalse(viewModel.configuredProviderIds.contains(APIKeySpyProvider.providerId)) + } + + func testDisablingPreservesKeyAndRemovesProviderFromPopover() throws { + let storage = ViewModelKeychainStorage() + let keychain = Keychain(storage: storage, service: "view-model") + try keychain.save("saved-key", for: APIKeySpyProvider.providerId) + ProviderEnablement.setEnabled(true, for: APIKeySpyProvider.providerId) + let registry = ProviderRegistry(keychain: keychain) + registry.register(APIKeySpyProvider()) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + + XCTAssertTrue(viewModel.configuredProviderIds.contains(APIKeySpyProvider.providerId)) + + viewModel.setProviderEnabled(false, for: APIKeySpyProvider.providerId) + + XCTAssertFalse(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertEqual(try keychain.load(for: APIKeySpyProvider.providerId), "saved-key") + XCTAssertFalse(viewModel.configuredProviderIds.contains(APIKeySpyProvider.providerId)) + + viewModel.setProviderEnabled(true, for: APIKeySpyProvider.providerId) + + XCTAssertTrue(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertTrue(viewModel.configuredProviderIds.contains(APIKeySpyProvider.providerId)) + } + + func testDisabledAPIKeyFreeProviderDoesNotProbeDuringViewModelOrSettingsReads() async { + let provider = APIKeyFreeSpyProvider() + let registry = ProviderRegistry() + registry.register(provider) + let viewModel = QuotaViewModel(registry: registry) + + _ = viewModel.registeredProvidersOrdered + _ = viewModel.canInstallHelper(for: APIKeyFreeSpyProvider.providerId) + _ = viewModel.credentialImportActionTitle(for: APIKeyFreeSpyProvider.providerId) + await Task.yield() + + XCTAssertFalse(viewModel.isEnabled(APIKeyFreeSpyProvider.providerId)) + XCTAssertEqual(provider.isConfiguredCallCount, 0) + XCTAssertEqual(provider.setupStateCallCount, 0) + XCTAssertEqual(provider.fetchCallCount, 0) + XCTAssertEqual(provider.canInstallCallCount, 0) + } + + func testDisablingRejectsAnInFlightQuotaResult() async throws { + let storage = ViewModelKeychainStorage() + let keychain = Keychain(storage: storage, service: "view-model") + try keychain.save("saved-key", for: DelayedAPIKeyProvider.providerId) + ProviderEnablement.setEnabled(true, for: DelayedAPIKeyProvider.providerId) + let provider = DelayedAPIKeyProvider() + let registry = ProviderRegistry(keychain: keychain) + registry.register(provider) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + + for _ in 0 ..< 100 where provider.fetchCallCount == 0 { + await Task.yield() + } + XCTAssertEqual(provider.fetchCallCount, 1) + + viewModel.setProviderEnabled(false, for: DelayedAPIKeyProvider.providerId) + provider.completeFetch() + for _ in 0 ..< 10 { + await Task.yield() + } + + XCTAssertFalse(viewModel.isEnabled(DelayedAPIKeyProvider.providerId)) + XCTAssertFalse(viewModel.configuredProviderIds.contains(DelayedAPIKeyProvider.providerId)) + if case .loaded = viewModel.providerStates[DelayedAPIKeyProvider.providerId] { + XCTFail("A result that completed after disable must not update state") + } + } + + func testDisablingOneProviderLeavesAnotherProviderEnabled() throws { + let storage = ViewModelKeychainStorage() + let keychain = Keychain(storage: storage, service: "view-model") + try keychain.save("first-key", for: APIKeySpyProvider.providerId) + try keychain.save("second-key", for: SecondaryAPIKeySpyProvider.providerId) + ProviderEnablement.setEnabled(true, for: APIKeySpyProvider.providerId) + ProviderEnablement.setEnabled(true, for: SecondaryAPIKeySpyProvider.providerId) + let registry = ProviderRegistry(keychain: keychain) + registry.register(APIKeySpyProvider()) + registry.register(SecondaryAPIKeySpyProvider()) + let viewModel = QuotaViewModel(keychain: keychain, registry: registry) + + viewModel.setProviderEnabled(false, for: APIKeySpyProvider.providerId) + + XCTAssertFalse(viewModel.isEnabled(APIKeySpyProvider.providerId)) + XCTAssertTrue(viewModel.isEnabled(SecondaryAPIKeySpyProvider.providerId)) + XCTAssertTrue(viewModel.configuredProviderIds.contains(SecondaryAPIKeySpyProvider.providerId)) + } +} + +private final class ViewModelKeychainStorage: KeychainStorage, @unchecked Sendable { + var data: Data? + var shouldFailWrites = false + + func readData( + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) throws -> Data? { + data + } + + func replaceData( + _ data: Data, + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) throws { + if shouldFailWrites { + throw KeychainStorageError.status(-1) + } + self.data = data + } + + func delete( + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) {} +} + +private struct APIKeySpyProvider: AIProvider { + static let providerId = "api-key-spy" + static let providerName = "API Key Spy" + static let providerDescription = "Test fixture" + static let baseURL = URL(string: "https://example.com")! + + func fetchQuota(auth _: ProviderAuth, baseURL _: URL) async throws -> ProviderQuota { + ProviderQuota( + providerId: Self.providerId, + providerName: Self.providerName, + headline: "Ready", + lines: [], + lastUpdated: Date() + ) + } +} + +private struct SecondaryAPIKeySpyProvider: AIProvider { + static let providerId = "secondary-api-key-spy" + static let providerName = "Secondary API Key Spy" + static let providerDescription = "Test fixture" + static let baseURL = URL(string: "https://example.com")! + + func fetchQuota(auth _: ProviderAuth, baseURL _: URL) async throws -> ProviderQuota { + ProviderQuota( + providerId: Self.providerId, + providerName: Self.providerName, + headline: "Ready", + lines: [], + lastUpdated: Date() + ) + } +} + +private final class DelayedAPIKeyProvider: AIProvider, @unchecked Sendable { + static let providerId = "delayed-api-key" + static let providerName = "Delayed API Key" + static let providerDescription = "Test fixture" + static let baseURL = URL(string: "https://example.com")! + + var fetchCallCount = 0 + private var continuation: CheckedContinuation? + + func fetchQuota(auth _: ProviderAuth, baseURL _: URL) async throws -> ProviderQuota { + fetchCallCount += 1 + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func completeFetch() { + continuation?.resume( + returning: ProviderQuota( + providerId: Self.providerId, + providerName: Self.providerName, + headline: "Late result", + lines: [], + lastUpdated: Date() + ) + ) + continuation = nil + } +} + +private final class APIKeyFreeSpyProvider: AIProvider, @unchecked Sendable { + static let providerId = "api-key-free-spy" + static let providerName = "API Key Free Spy" + static let providerDescription = "Test fixture" + static let baseURL = URL(string: "https://example.com")! + static let authShape: ProviderAuth.Shape = .apiKeyFree + static let credentialImportActionTitle: String? = "Import credentials" + + var isConfiguredCallCount = 0 + var setupStateCallCount = 0 + var fetchCallCount = 0 + var canInstallCallCount = 0 + + func isConfigured() -> Bool { + isConfiguredCallCount += 1 + return true + } + + func currentSetupState() async -> ProviderState? { + setupStateCallCount += 1 + return nil + } + + func fetchQuota(auth _: ProviderAuth, baseURL _: URL) async throws -> ProviderQuota { + fetchCallCount += 1 + return ProviderQuota( + providerId: Self.providerId, + providerName: Self.providerName, + headline: "Ready", + lines: [], + lastUpdated: Date() + ) + } + + func canInstallHelper() -> Bool { + canInstallCallCount += 1 + return true + } +} diff --git a/Tests/CoreTests/ProviderEnablementTests.swift b/Tests/CoreTests/ProviderEnablementTests.swift new file mode 100644 index 0000000..3458782 --- /dev/null +++ b/Tests/CoreTests/ProviderEnablementTests.swift @@ -0,0 +1,229 @@ +@testable import Core +import Foundation +import XCTest + +@MainActor +final class ProviderEnablementTests: XCTestCase { + private let suiteName = "filbert.tests.provider-enablement" + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(.standard) + defaults = nil + super.tearDown() + } + + func testMissingAPIKeyDefaultsToDisabledAndPersistsResolution() { + let storage = InMemoryStorage() + let keychain = Keychain(storage: storage, service: "enablement") + + XCTAssertFalse( + ProviderEnablement.isEnabled( + for: "api-key-provider", + authShape: .apiKey, + keychain: keychain + ) + ) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: "api-key-provider"), false) + XCTAssertEqual(storage.readCount, 1) + } + + func testStoredAPIKeyDefaultsToEnabledAndPersistsResolution() throws { + let storage = InMemoryStorage() + let keychain = Keychain(storage: storage, service: "enablement") + try keychain.save("saved-key", for: "api-key-provider") + + XCTAssertTrue( + ProviderEnablement.isEnabled( + for: "api-key-provider", + authShape: .apiKey, + keychain: keychain + ) + ) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: "api-key-provider"), true) + } + + func testAPIKeyFreeProviderDefaultsToDisabledWithoutReadingKeychain() { + let storage = InMemoryStorage() + let keychain = Keychain(storage: storage, service: "enablement") + + XCTAssertFalse( + ProviderEnablement.isEnabled( + for: "api-key-free-provider", + authShape: .apiKeyFree, + keychain: keychain + ) + ) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: "api-key-free-provider"), false) + XCTAssertEqual(storage.readCount, 0) + } + + func testExplicitValueSurvivesLaterKeychainChangesAndStoreRecreation() throws { + let storage = InMemoryStorage() + let keychain = Keychain(storage: storage, service: "enablement") + ProviderEnablement.setEnabled(false, for: "api-key-provider") + try keychain.save("saved-key", for: "api-key-provider") + + XCTAssertFalse( + ProviderEnablement.isEnabled( + for: "api-key-provider", + authShape: .apiKey, + keychain: keychain + ) + ) + + let recreatedDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + ProviderEnablement.setUserDefaults(recreatedDefaults) + XCTAssertEqual(ProviderEnablement.savedEnabled(for: "api-key-provider"), false) + } + + func testDisabledProviderReceivesNoRegistryOperationCalls() async { + let provider = GateSpyProvider() + let registry = ProviderRegistry() + registry.register(provider) + ProviderEnablement.setEnabled(false, for: GateSpyProvider.providerId) + + XCTAssertFalse(registry.isConfigured(GateSpyProvider.providerId)) + let fetchAllResults = await registry.fetchAll() + let scopedFetchResult = await registry.fetchQuota(for: GateSpyProvider.providerId) + let setupStates = await registry.refreshSetupStates() + let setupState = await registry.refreshSetupState(for: GateSpyProvider.providerId) + XCTAssertTrue(fetchAllResults.isEmpty) + XCTAssertNil(scopedFetchResult) + XCTAssertTrue(setupStates.isEmpty) + XCTAssertNil(setupState) + XCTAssertFalse(registry.canInstallHelper(for: GateSpyProvider.providerId)) + XCTAssertNil(registry.credentialImportActionTitle(for: GateSpyProvider.providerId)) + + await assertNotSupported { + try await registry.installHelper(for: GateSpyProvider.providerId) + } + await assertNotSupported { + try await registry.removeHelper(for: GateSpyProvider.providerId) + } + await assertNotSupported { + try await registry.importCredentials(for: GateSpyProvider.providerId) + } + await assertNotSupported { + try await registry.proactiveRefresh(for: GateSpyProvider.providerId) + } + + XCTAssertEqual(provider.isConfiguredCallCount, 0) + XCTAssertEqual(provider.setupStateCallCount, 0) + XCTAssertEqual(provider.fetchCallCount, 0) + XCTAssertEqual(provider.canInstallCallCount, 0) + XCTAssertEqual(provider.installCallCount, 0) + XCTAssertEqual(provider.removeCallCount, 0) + XCTAssertEqual(provider.importCallCount, 0) + XCTAssertEqual(provider.proactiveRefreshCallCount, 0) + } + + private func assertNotSupported( + _ operation: () async throws -> Void + ) async { + do { + try await operation() + XCTFail("Expected ProviderSetupError.notSupported") + } catch ProviderSetupError.notSupported { + } catch { + XCTFail("Expected ProviderSetupError.notSupported, got \(error)") + } + } +} + +private final class InMemoryStorage: KeychainStorage, @unchecked Sendable { + var data: Data? + var readCount = 0 + + func readData( + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) throws -> Data? { + readCount += 1 + return data + } + + func replaceData( + _ data: Data, + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) throws { + self.data = data + } + + func delete( + service _: String, + account _: String, + authenticationContext _: KeychainAuthenticationContext + ) {} +} + +private final class GateSpyProvider: AIProvider, ProactiveRefreshable, @unchecked Sendable { + static let providerId = "gate-spy" + static let providerName = "Gate Spy" + static let providerDescription = "Test fixture" + static let baseURL = URL(string: "https://example.com")! + static let authShape: ProviderAuth.Shape = .apiKeyFree + static let credentialImportActionTitle: String? = "Import credentials" + + var isConfiguredCallCount = 0 + var setupStateCallCount = 0 + var fetchCallCount = 0 + var canInstallCallCount = 0 + var installCallCount = 0 + var removeCallCount = 0 + var importCallCount = 0 + var proactiveRefreshCallCount = 0 + + func isConfigured() -> Bool { + isConfiguredCallCount += 1 + return true + } + + func currentSetupState() async -> ProviderState? { + setupStateCallCount += 1 + return nil + } + + func fetchQuota(auth _: ProviderAuth, baseURL _: URL) async throws -> ProviderQuota { + fetchCallCount += 1 + return ProviderQuota( + providerId: Self.providerId, + providerName: Self.providerName, + headline: "Ready", + lines: [], + lastUpdated: Date() + ) + } + + func canInstallHelper() -> Bool { + canInstallCallCount += 1 + return true + } + + func installHelper() async throws { + installCallCount += 1 + } + + func removeHelper() async throws { + removeCallCount += 1 + } + + func importCredentials() async throws { + importCallCount += 1 + } + + func proactiveRefresh() async throws { + proactiveRefreshCallCount += 1 + } +} diff --git a/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift b/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift index be00966..67158d6 100644 --- a/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift +++ b/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift @@ -1,14 +1,33 @@ import Core +import Foundation import XCTest @MainActor final class ProviderRegistryProactiveRefreshTests: XCTestCase { + private let suiteName = "filbert.tests.provider-registry-proactive-refresh" + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + ProviderEnablement.setUserDefaults(.standard) + defaults = nil + super.tearDown() + } + // MARK: - routes to a conforming provider func testProactiveRefresh_routesToConformingProvider() async throws { let registry = ProviderRegistry() let provider = FakeProactiveRefreshProvider() registry.register(provider) + ProviderEnablement.setEnabled(true, for: FakeProactiveRefreshProvider.providerId) try await registry.proactiveRefresh(for: FakeProactiveRefreshProvider.providerId) @@ -20,6 +39,7 @@ final class ProviderRegistryProactiveRefreshTests: XCTestCase { func testProactiveRefresh_throwsNotSupported_forNonConformingProvider() async { let registry = ProviderRegistry() registry.register(FakeNonRefreshableProvider()) + ProviderEnablement.setEnabled(true, for: FakeNonRefreshableProvider.providerId) do { try await registry.proactiveRefresh(for: FakeNonRefreshableProvider.providerId) @@ -48,6 +68,7 @@ final class ProviderRegistryProactiveRefreshTests: XCTestCase { let registry = ProviderRegistry() let provider = FakeCredentialImportProvider() registry.register(provider) + ProviderEnablement.setEnabled(true, for: FakeCredentialImportProvider.providerId) XCTAssertEqual( registry.credentialImportActionTitle(for: FakeCredentialImportProvider.providerId), @@ -61,6 +82,7 @@ final class ProviderRegistryProactiveRefreshTests: XCTestCase { func testCredentialImport_isUnavailableForDefaultProvider() async { let registry = ProviderRegistry() registry.register(FakeNonRefreshableProvider()) + ProviderEnablement.setEnabled(true, for: FakeNonRefreshableProvider.providerId) XCTAssertNil(registry.credentialImportActionTitle(for: FakeNonRefreshableProvider.providerId)) do { diff --git a/specs/ui/17-provider-enablement.md b/specs/ui/17-provider-enablement.md new file mode 100644 index 0000000..c8ea84e --- /dev/null +++ b/specs/ui/17-provider-enablement.md @@ -0,0 +1,145 @@ +## Objective + +Make every provider explicitly enableable in Settings and prevent disabled providers from running setup discovery, credential import, refresh, synchronization, or quota gathering while preserving their keys and local installations. + +## Context + +- `Sources/Core/ProviderEnablement.swift` — new provider-ID-neutral `UserDefaults` storage for the user's enabled/disabled choice; enablement is preference state, not a secret. +- `Sources/Core/ProviderRegistry.swift` — becomes the enforcement boundary that rejects work for disabled providers before reading provider-owned state or invoking an `AIProvider` method. +- `Sources/Core/ProviderProtocol.swift` — keeps authentication shape and provider configuration separate from enablement; no provider-specific enablement requirement is added to individual provider modules. +- `Sources/App/QuotaViewModel.swift` — owns the observable enabled set, starts work only for enabled providers, and stops provider-owned tasks when a provider is disabled. +- `Sources/App/SettingsView.swift` and `Sources/App/SettingsVisualComponents.swift` — add one enable/disable toggle to every provider card and distinguish disabled, enabled-but-unconfigured, and ready states. +- `Sources/Providers/Cursor/CursorProvider.swift` — its `isConfigured()` and `currentSetupState()` paths can bootstrap credentials from Cursor-owned storage, so neither may be called while Cursor is disabled (providers 07 AC4, core 04 AC4). +- This changes the implicit configuration-based activation introduced by (ui 02 AC3, AC4, AC5, AC7) and the automatic API-key-free setup checks from (core 03 AC5, AC6) into an explicit enablement gate. +- Existing API-key-free setup and controls remain as specified by (ui 05), but they are reachable only after the user enables that provider. + +## Acceptance Criteria + +### AC1: Enablement and configuration are independent + +- **Given** any registered provider +- **When** Core evaluates its lifecycle +- **Then** it treats `isEnabled` and `isConfigured` as separate predicates +- **And** a provider runs only when both predicates are true +- **And** authentication shape, a saved key, an installed CLI, a helper, cached data, or imported credentials do not override an explicit disabled choice +- **And** the implementation remains provider-ID-neutral and never branches on `"cursor"`, `"claude-code"`, or another provider ID. + +### AC2: Every provider has a persistent Settings toggle + +- **Given** the Providers tab lists the registered providers +- **When** a provider card renders +- **Then** its header contains a localized, accessible enable/disable `Toggle` +- **And** changing the toggle persists a Boolean for that provider ID in `UserDefaults` +- **And** the choice survives app relaunches and is independent of provider ordering, collapse state, credentials, and provider-owned files +- **And** adding a future provider requires no Settings or App-layer branch beyond registration (ui 02 AC9). + +### AC3: New and upgraded installations preserve intentional opt-in + +- **Given** no explicit enablement value exists for a provider +- **When** its initial value is resolved +- **Then** an API-key provider with a key already stored in Filbert's consolidated Keychain is enabled and the resolved value is persisted +- **And** an API-key provider without a stored key is disabled +- **And** every API-key-free provider is disabled, even if its CLI, helper, cache, or externally owned credentials can be found +- **And** once an explicit value exists, it is authoritative and no later credential or installation check changes it +- **And** this resolution reads only Filbert's own Keychain for API-key providers; it invokes no provider method and reads no CLI-owned Keychain item, SQLite database, settings file, cache, executable path, or network resource. + +### AC4: Saving an API key enables the provider in the same action + +- **Given** an API-key provider is disabled or enabled-but-unconfigured +- **When** the user submits a valid non-empty key in Settings +- **Then** Filbert saves the key first and enables the provider only after that save succeeds +- **And** one successful submission updates the toggle, configuration state, popover membership, refresh scheduling, and initial quota fetch without a second user action +- **And** a failed Keychain save leaves the previous enablement value unchanged and surfaces the existing inline error +- **And** clearing a key stops fetches by making the enabled provider unconfigured but does not silently change its toggle. + +### AC5: Manual disable preserves configuration + +- **Given** an enabled provider has a saved API key, installed CLI or helper, imported credentials, cached quota, or a custom base URL +- **When** the user switches the provider off +- **Then** Filbert persists the disabled choice and removes the provider from the popover immediately +- **And** it stops that provider's refresh loop, cancels Filbert-owned pending work for it where cancellation is supported, clears its transient loading/refresh presentation, and ignores any late result +- **And** it does not delete or modify the API key, imported credentials, helper, CLI installation, cache, custom base URL, or provider-owned data +- **And** switching the provider on later reuses the preserved configuration. + +### AC6: Disabled providers have a hard no-work boundary + +- **Given** a provider is disabled +- **When** the app launches, Settings opens, a global refresh runs, another provider refreshes, five minutes elapse, or provider ordering changes +- **Then** the registry does not call that provider's `isConfigured()`, `currentSetupState()`, `fetchQuota(...)`, `proactiveRefresh()`, `canInstallHelper()`, `installHelper()`, `removeHelper()`, or `importCredentials()` +- **And** no provider-owned filesystem lookup, executable discovery, helper migration, external credential bootstrap, cache read, token refresh, network request, or synchronization is started on its behalf +- **And** reading static `ProviderInfo` metadata to render the Settings card is allowed +- **And** API-key Settings actions may read or update Filbert's own Keychain only in direct response to the user. + +### AC7: Enabling starts only the work appropriate to the provider + +- **Given** a disabled provider +- **When** the user switches it on +- **Then** an API-key provider with a saved key begins its initial fetch and five-minute refresh loop +- **And** an API-key provider without a key remains enabled-but-unconfigured and performs no fetch until a key is saved +- **And** an API-key-free provider may then run its provider-owned configuration and setup-state checks +- **And** a configured API-key-free provider begins its initial fetch and refresh loop +- **And** an unconfigured API-key-free provider shows its existing setup guidance and performs no quota fetch until setup succeeds. + +### AC8: Settings communicates disabled state without probing the provider + +- **Given** a provider is disabled +- **When** its Settings card renders +- **Then** the status presentation says "Disabled" regardless of stored configuration +- **And** an API-key-free card shows a localized prompt to enable the provider before setup and does not show install, remove, import, or provider-derived setup controls +- **And** an API-key card retains its key-management controls so saving a key can satisfy AC4 +- **And** enabled cards continue to show their configured, ready, setup-needed, loading, and error presentations from (ui 05, ui 15). + +### AC9: Popover and refresh paths include only enabled, configured providers + +- **Given** any mix of enabled, disabled, configured, and unconfigured providers +- **When** the popover derives its provider list or a refresh path executes +- **Then** only providers that are both enabled and configured appear in `configuredProviderIds` and the popover +- **And** initial bulk refresh filters disabled and unconfigured providers before creating child tasks +- **And** manual and automatic per-provider refresh use a provider-scoped registry operation rather than causing `fetchAll()` to call unrelated providers +- **And** one provider's enablement change does not restart, fetch, or cancel another provider. + +### AC10: Registry gates every provider operation + +- **Given** App code accidentally requests an operation for a disabled provider +- **When** the request reaches `ProviderRegistry` +- **Then** the registry returns without invoking the provider +- **And** setup, helper, credential-import, proactive-refresh, and quota APIs share the same enablement check +- **And** UI gating is treated as presentation convenience rather than the only safety boundary +- **And** registry tests verify the provider spy receives zero calls for every gated operation. + +### AC11: Enablement behavior is tested across auth shapes and relaunches + +- **Given** isolated `UserDefaults`, Keychain stubs, and provider spies +- **When** the Core and App test suites run +- **Then** they cover fresh defaults, the API-key upgrade rule, API-key-free default-off behavior, explicit values overriding later configuration changes, persistence across a recreated store, save-key auto-enable, clear-key independence, disable/re-enable with preserved configuration, late-result rejection, and provider isolation +- **And** a Cursor-shaped spy proves that launch and Settings rendering perform zero configuration, bootstrap, setup-state, import, filesystem, or fetch calls until its toggle is enabled +- **And** all existing provider suites continue to pass without adding enablement logic to individual provider implementations. + +### AC12: New UI copy is localized and accessible + +- **Given** the app runs in any supported locale or with assistive technology +- **When** the enablement control and disabled guidance render +- **Then** every new user-facing string is stored in the App String Catalog and resolved through `String(localized:)` +- **And** the toggle has a provider-specific accessibility label and value +- **And** enabled, disabled, configured, and error states are not communicated by color alone (ui 15 AC5). + +## Plan + +1. [x] Add a `ProviderEnablement` value in Core, backed by a `[String: Bool]` dictionary in `UserDefaults`, with test injection matching `ProviderOrder` and `ProviderCollapseState`. Resolve and persist a missing value using AC3 without invoking an API-key-free provider. +2. [x] Inject or otherwise share the enablement source with `ProviderRegistry`. Gate `isConfigured`, setup-state refresh, helper management, credential import, proactive refresh, bulk fetch, and provider-scoped fetch before any provider callback. +3. [x] Add a provider-scoped quota-fetch entry point. Keep `fetchAll()` for the one initial bulk refresh, but filter its task group to enabled, configured providers; use the scoped entry point for manual refresh and each five-minute loop. +4. [x] Add observable enablement state and a toggle action to `QuotaViewModel`. Enabling resolves setup/configuration before starting eligible work. Disabling stops the provider loop, cancels tracked work where possible, discards late results, removes the provider from derived popover state, and leaves persistent configuration untouched. +5. [x] Update key save and clear flows. A successful save persists enablement before starting the initial fetch; a failed save does not alter it. Clearing the key retains the toggle but stops work because configuration is now absent. +6. [x] Add the toggle to the shared provider-card header. Disabled API-key-free cards render static guidance without evaluating provider setup capabilities; disabled API-key cards keep key-management controls. +7. [x] Add localized App String Catalog entries for the toggle, disabled status, disabled guidance, and accessibility text. +8. [x] Add Core and App tests for AC3 through AC11, including spies for every provider protocol hook and isolated persistence tests. + +No production code is written until this spec is reviewed. + +## Risks + +- **Upgrade behavior intentionally differs by auth shape.** Existing API keys are strong evidence of prior opt-in, so they migrate to enabled. An installed CLI or discoverable local session is not evidence that the user opted Filbert into reading it, so every API-key-free provider migrates to disabled. +- **Disabling cannot undo a provider call that already began while enabled.** Filbert cancels work it owns and rejects late results, but a network request or filesystem operation may already have crossed its side-effect boundary. The hard guarantee is that no new provider call starts after the disabled value is applied. +- **Enablement must be enforced below the UI.** A toggle-only implementation would leave launch, bulk refresh, setup-state, and future call sites able to probe disabled providers. The registry gate and zero-call spy tests are required to prevent that regression. +- **Keychain migration may still prompt once.** Resolving missing enablement for an existing API-key provider can require reading Filbert's consolidated Keychain item. It never accesses provider-owned credential stores, and the resolved Boolean is persisted so the migration is not repeated. +- **Enabled-but-unconfigured is a real state.** Clearing a key no longer implies disabling the provider. UI derivation must keep the toggle on while omitting the provider from the popover and suppressing refresh work.