From b12eaa36bce96cd77f55bc8e3584b48b8f0db023 Mon Sep 17 00:00:00 2001 From: Victor Quiroz Date: Sun, 26 Jul 2026 13:25:25 +0200 Subject: [PATCH 1/2] feat: add explicit provider enablement toggle in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users enable or disable each provider from the Settings panel. Disabled providers are fully gated at the registry level — no configuration checks, setup probes, fetches, or helper operations run for them. Stored credentials and local installations are preserved when a provider is disabled. --- Sources/App/APIKeyFreeSettingsRow.swift | 131 +++++++ Sources/App/QuotaViewModel+Lifecycle.swift | 140 +++++++ Sources/App/QuotaViewModel+Presentation.swift | 51 +++ Sources/App/QuotaViewModel+Results.swift | 56 +++ Sources/App/QuotaViewModel+Setup.swift | 63 +++ Sources/App/QuotaViewModel.swift | 362 +++--------------- Sources/App/Resources/Localizable.xcstrings | 36 ++ Sources/App/SettingsView.swift | 259 +++++-------- Sources/App/SettingsVisualComponents.swift | 30 ++ Sources/Core/Keychain.swift | 2 +- Sources/Core/ProviderEnablement.swift | 50 +++ Sources/Core/ProviderRegistry.swift | 149 ++++--- .../ConfiguredProvidersOrderedTests.swift | 20 + .../CredentialImportViewModelTests.swift | 19 + .../ProviderEnablementViewModelTests.swift | 294 ++++++++++++++ Tests/CoreTests/ProviderEnablementTests.swift | 229 +++++++++++ ...roviderRegistryProactiveRefreshTests.swift | 22 ++ specs/ui/17-provider-enablement.md | 145 +++++++ 18 files changed, 1550 insertions(+), 508 deletions(-) create mode 100644 Sources/App/APIKeyFreeSettingsRow.swift create mode 100644 Sources/App/QuotaViewModel+Lifecycle.swift create mode 100644 Sources/App/QuotaViewModel+Presentation.swift create mode 100644 Sources/App/QuotaViewModel+Results.swift create mode 100644 Sources/App/QuotaViewModel+Setup.swift create mode 100644 Sources/Core/ProviderEnablement.swift create mode 100644 Tests/AppTests/ProviderEnablementViewModelTests.swift create mode 100644 Tests/CoreTests/ProviderEnablementTests.swift create mode 100644 specs/ui/17-provider-enablement.md 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..e2103da 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,28 @@ 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) + .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 +255,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. From 4ebb6ab8a0e6f5cf3d944747a7091170ebbb2006 Mon Sep 17 00:00:00 2001 From: Victor Quiroz Date: Sun, 26 Jul 2026 13:30:11 +0200 Subject: [PATCH 2/2] feat(settings): style enable toggle with caption font and secondary color --- Sources/App/SettingsVisualComponents.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/App/SettingsVisualComponents.swift b/Sources/App/SettingsVisualComponents.swift index e2103da..27a44f3 100644 --- a/Sources/App/SettingsVisualComponents.swift +++ b/Sources/App/SettingsVisualComponents.swift @@ -134,6 +134,8 @@ struct SettingsCardHeader: View { ) .toggleStyle(.switch) .controlSize(.small) + .font(.caption) + .foregroundStyle(.secondary) .accessibilityLabel( String.localizedStringWithFormat( String(localized: "Enable %@"),