diff --git a/CHANGELOG.md b/CHANGELOG.md index 2339de5aa0..8f29828334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Theme color wells and the theme preview unreadable to VoiceOver. - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. +- Settings > AI reading sign-in tokens and running command line tools for providers that were never added. ### Security diff --git a/TablePro/Core/AI/AIProviderAccountProbes.swift b/TablePro/Core/AI/AIProviderAccountProbes.swift new file mode 100644 index 0000000000..6f3af7d7f9 --- /dev/null +++ b/TablePro/Core/AI/AIProviderAccountProbes.swift @@ -0,0 +1,17 @@ +// +// AIProviderAccountProbes.swift +// TablePro +// + +import Foundation + +/// A sign-in status in Settings > AI costs a Keychain read or a spawned CLI, so it is checked only +/// for a provider the user has added, and not at all while AI is off. +enum AIProviderAccountProbes { + static let probedTypes: Set = [.chatgptCodex, .claudeAgent] + + static func types(toProbe settings: AISettings) -> Set { + guard settings.enabled else { return [] } + return Set(settings.providers.map(\.type)).intersection(probedTypes) + } +} diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 0ef8c928fe..825057a2c2 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -34,11 +34,9 @@ struct AISettingsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .task { refreshKeyAvailability() } - .task { await chatGPTCodexService.refreshAuthState() } - .task { await claudeAgentService.performRefresh() } - .onChange(of: settings.providers.map(\.id)) { _ in - refreshKeyAvailability() + .task(id: settings.enabled ? settings.providers.map(\.id) : []) { refreshKeyAvailability() } + .task(id: AIProviderAccountProbes.types(toProbe: settings)) { + await refreshAccountStatus(of: AIProviderAccountProbes.types(toProbe: settings)) } .sheet(item: editingProviderBinding) { provider in AIProviderDetailSheet( @@ -418,7 +416,21 @@ struct AISettingsView: View { return String(localized: "Not configured") } + private func refreshAccountStatus(of types: Set) async { + let chatGPT = chatGPTCodexService + let claudeAgent = claudeAgentService + await withTaskGroup(of: Void.self) { group in + if types.contains(.chatgptCodex) { + group.addTask { await chatGPT.refreshAuthState() } + } + if types.contains(.claudeAgent) { + group.addTask { await claudeAgent.performRefresh() } + } + } + } + private func refreshKeyAvailability() { + guard settings.enabled else { return } var ids: Set = [] for provider in settings.providers where provider.authStyle.usesAPIKey { if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty { diff --git a/TableProTests/Core/AI/AIProviderAccountProbesTests.swift b/TableProTests/Core/AI/AIProviderAccountProbesTests.swift new file mode 100644 index 0000000000..bfd0fb872e --- /dev/null +++ b/TableProTests/Core/AI/AIProviderAccountProbesTests.swift @@ -0,0 +1,43 @@ +// +// AIProviderAccountProbesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct AIProviderAccountProbesTests { + private func settings(enabled: Bool = true, _ types: AIProviderType...) -> AISettings { + AISettings(enabled: enabled, providers: types.map { AIProviderConfig(type: $0) }) + } + + @Test("Nothing is probed when no provider is configured") + func noProvidersProbesNothing() { + #expect(AIProviderAccountProbes.types(toProbe: settings()).isEmpty) + } + + @Test("Nothing is probed while AI is off, even with sign-in providers configured") + func aiOffProbesNothing() { + let configured = settings(enabled: false, .claudeAgent, .chatgptCodex) + #expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty) + } + + @Test("Only the configured sign-in providers are probed") + func onlyConfiguredProvidersAreProbed() { + let configured = settings(.claudeAgent, .openAI, .gemini) + #expect(AIProviderAccountProbes.types(toProbe: configured) == [.claudeAgent]) + } + + @Test("Every configured sign-in provider is probed once") + func everyConfiguredSignInProviderIsProbed() { + let configured = settings(.chatgptCodex, .claudeAgent, .claudeAgent) + #expect(AIProviderAccountProbes.types(toProbe: configured) == [.chatgptCodex, .claudeAgent]) + } + + @Test("API key providers never need an account probe") + func apiKeyProvidersAreNotProbed() { + let configured = settings(.claude, .openAI, .openRouter, .gemini, .xai, .ollama, .custom, .copilot) + #expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty) + } +}