diff --git a/VoiceInk/Models/TranscriptionModel.swift b/VoiceInk/Models/TranscriptionModel.swift index 2617682..3276682 100644 --- a/VoiceInk/Models/TranscriptionModel.swift +++ b/VoiceInk/Models/TranscriptionModel.swift @@ -91,7 +91,7 @@ struct CloudModel: TranscriptionModel { } } -// A new struct for custom cloud models +/// Custom cloud model with API key stored in Keychain. struct CustomCloudModel: TranscriptionModel, Codable { let id: UUID let name: String @@ -99,22 +99,59 @@ struct CustomCloudModel: TranscriptionModel, Codable { let description: String let provider: ModelProvider = .custom let apiEndpoint: String - let apiKey: String let modelName: String let isMultilingualModel: Bool let supportedLanguages: [String: String] - init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, apiKey: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) { + /// API key retrieved from Keychain by model ID. + var apiKey: String { + APIKeyManager.shared.getCustomModelAPIKey(forModelId: id) ?? "" + } + + init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) { self.id = id self.name = name self.displayName = displayName self.description = description self.apiEndpoint = apiEndpoint - self.apiKey = apiKey self.modelName = modelName self.isMultilingualModel = isMultilingual self.supportedLanguages = supportedLanguages ?? PredefinedModels.getLanguageDictionary(isMultilingual: isMultilingual) } + + /// Custom Codable to migrate legacy apiKey from JSON to Keychain. + private enum CodingKeys: String, CodingKey { + case id, name, displayName, description, apiEndpoint, modelName, isMultilingualModel, supportedLanguages + case apiKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + displayName = try container.decode(String.self, forKey: .displayName) + description = try container.decode(String.self, forKey: .description) + apiEndpoint = try container.decode(String.self, forKey: .apiEndpoint) + modelName = try container.decode(String.self, forKey: .modelName) + isMultilingualModel = try container.decode(Bool.self, forKey: .isMultilingualModel) + supportedLanguages = try container.decode([String: String].self, forKey: .supportedLanguages) + + if let legacyApiKey = try container.decodeIfPresent(String.self, forKey: .apiKey), !legacyApiKey.isEmpty { + APIKeyManager.shared.saveCustomModelAPIKey(legacyApiKey, forModelId: id) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(displayName, forKey: .displayName) + try container.encode(description, forKey: .description) + try container.encode(apiEndpoint, forKey: .apiEndpoint) + try container.encode(modelName, forKey: .modelName) + try container.encode(isMultilingualModel, forKey: .isMultilingualModel) + try container.encode(supportedLanguages, forKey: .supportedLanguages) + } } struct LocalModel: TranscriptionModel { diff --git a/VoiceInk/Services/AIEnhancement/AIService.swift b/VoiceInk/Services/AIEnhancement/AIService.swift index 79bd128..dd0d833 100644 --- a/VoiceInk/Services/AIEnhancement/AIService.swift +++ b/VoiceInk/Services/AIEnhancement/AIService.swift @@ -167,7 +167,7 @@ class AIService: ObservableObject { didSet { userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider") if selectedProvider.requiresAPIKey { - if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) { self.apiKey = savedKey self.isAPIKeyValid = true } else { @@ -199,7 +199,7 @@ class AIService: ObservableObject { if provider == .ollama { return ollamaService.isConnected } else if provider.requiresAPIKey { - return userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil + return APIKeyManager.shared.hasAPIKey(forProvider: provider.rawValue) } return false } @@ -230,16 +230,16 @@ class AIService: ObservableObject { } else { self.selectedProvider = .gemini } - + if selectedProvider.requiresAPIKey { - if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) { self.apiKey = savedKey self.isAPIKeyValid = true } } else { self.isAPIKeyValid = true } - + loadSavedModelSelections() loadSavedOpenRouterModels() } @@ -283,14 +283,14 @@ class AIService: ObservableObject { completion(true, nil) return } - + verifyAPIKey(key) { [weak self] isValid, errorMessage in guard let self = self else { return } DispatchQueue.main.async { if isValid { self.apiKey = key self.isAPIKeyValid = true - self.userDefaults.set(key, forKey: "\(self.selectedProvider.rawValue)APIKey") + APIKeyManager.shared.saveAPIKey(key, forProvider: self.selectedProvider.rawValue) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) } else { self.isAPIKeyValid = false @@ -515,10 +515,10 @@ class AIService: ObservableObject { func clearAPIKey() { guard selectedProvider.requiresAPIKey else { return } - + apiKey = "" isAPIKeyValid = false - userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey") + APIKeyManager.shared.deleteAPIKey(forProvider: selectedProvider.rawValue) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) } diff --git a/VoiceInk/Services/APIKeyManager.swift b/VoiceInk/Services/APIKeyManager.swift new file mode 100644 index 0000000..06b9e4b --- /dev/null +++ b/VoiceInk/Services/APIKeyManager.swift @@ -0,0 +1,225 @@ +import Foundation +import os + +/// Manages API keys using secure Keychain storage with automatic migration from UserDefaults. +final class APIKeyManager { + static let shared = APIKeyManager() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "APIKeyManager") + private let keychain = KeychainService.shared + private let userDefaults = UserDefaults.standard + + private let migrationCompletedKey = "APIKeyMigrationToKeychainCompleted_v2" + + /// Provider to Keychain identifier mapping (iOS compatible for iCloud sync). + private static let providerToKeychainKey: [String: String] = [ + "groq": "groqAPIKey", + "deepgram": "deepgramAPIKey", + "cerebras": "cerebrasAPIKey", + "gemini": "geminiAPIKey", + "mistral": "mistralAPIKey", + "elevenlabs": "elevenLabsAPIKey", + "soniox": "sonioxAPIKey", + "openai": "openAIAPIKey", + "anthropic": "anthropicAPIKey", + "openrouter": "openRouterAPIKey" + ] + + /// Legacy UserDefaults to Keychain key mapping for migration. + private static let userDefaultsToKeychainMapping: [String: String] = [ + "GROQAPIKey": "groqAPIKey", + "DeepgramAPIKey": "deepgramAPIKey", + "CerebrasAPIKey": "cerebrasAPIKey", + "GeminiAPIKey": "geminiAPIKey", + "MistralAPIKey": "mistralAPIKey", + "ElevenLabsAPIKey": "elevenLabsAPIKey", + "SonioxAPIKey": "sonioxAPIKey", + "OpenAIAPIKey": "openAIAPIKey", + "AnthropicAPIKey": "anthropicAPIKey", + "OpenRouterAPIKey": "openRouterAPIKey" + ] + + private init() { + migrateFromUserDefaultsIfNeeded() + } + + // MARK: - Standard Provider API Keys + + /// Saves an API key for a provider. + @discardableResult + func saveAPIKey(_ key: String, forProvider provider: String) -> Bool { + let keyIdentifier = keychainIdentifier(forProvider: provider) + let success = keychain.save(key, forKey: keyIdentifier) + if success { + logger.info("Saved API key for provider: \(provider) with key: \(keyIdentifier)") + // Clean up any remaining UserDefaults entries (both old and new format) + cleanupUserDefaultsForProvider(provider) + } + return success + } + + /// Retrieves an API key for a provider. + func getAPIKey(forProvider provider: String) -> String? { + let keyIdentifier = keychainIdentifier(forProvider: provider) + + // First try Keychain with new identifier + if let key = keychain.getString(forKey: keyIdentifier), !key.isEmpty { + return key + } + + let oldKey = oldUserDefaultsKey(forProvider: provider) + if let key = userDefaults.string(forKey: oldKey), !key.isEmpty { + logger.info("Migrating \(oldKey) to Keychain") + keychain.save(key, forKey: keyIdentifier) + userDefaults.removeObject(forKey: oldKey) + return key + } + + return nil + } + + /// Deletes an API key for a provider. + @discardableResult + func deleteAPIKey(forProvider provider: String) -> Bool { + let keyIdentifier = keychainIdentifier(forProvider: provider) + let success = keychain.delete(forKey: keyIdentifier) + cleanupUserDefaultsForProvider(provider) + if success { + logger.info("Deleted API key for provider: \(provider)") + } + return success + } + + /// Checks if an API key exists for a provider. + func hasAPIKey(forProvider provider: String) -> Bool { + return getAPIKey(forProvider: provider) != nil + } + + // MARK: - Custom Model API Keys + + /// Saves an API key for a custom model. + @discardableResult + func saveCustomModelAPIKey(_ key: String, forModelId modelId: UUID) -> Bool { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + let success = keychain.save(key, forKey: keyIdentifier) + if success { + logger.info("Saved API key for custom model: \(modelId.uuidString)") + } + return success + } + + /// Retrieves an API key for a custom model. + func getCustomModelAPIKey(forModelId modelId: UUID) -> String? { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + return keychain.getString(forKey: keyIdentifier) + } + + /// Deletes an API key for a custom model. + @discardableResult + func deleteCustomModelAPIKey(forModelId modelId: UUID) -> Bool { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + let success = keychain.delete(forKey: keyIdentifier) + if success { + logger.info("Deleted API key for custom model: \(modelId.uuidString)") + } + return success + } + + // MARK: - Migration + + /// Migrates API keys from UserDefaults to Keychain on first run. + private func migrateFromUserDefaultsIfNeeded() { + if userDefaults.bool(forKey: migrationCompletedKey) { + return + } + + logger.info("Starting API key migration") + var migratedCount = 0 + + for (oldKey, newKey) in Self.userDefaultsToKeychainMapping { + if let value = userDefaults.string(forKey: oldKey), !value.isEmpty { + if keychain.save(value, forKey: newKey) { + userDefaults.removeObject(forKey: oldKey) + migratedCount += 1 + } else { + logger.error("Failed to migrate \(oldKey)") + } + } + } + + migrateCustomModelAPIKeys() + userDefaults.set(true, forKey: migrationCompletedKey) + logger.info("Migration completed. Migrated \(migratedCount) API keys.") + } + + /// Migrates custom model API keys from UserDefaults. + private func migrateCustomModelAPIKeys() { + guard let data = userDefaults.data(forKey: "customCloudModels") else { + return + } + + struct LegacyCustomCloudModel: Codable { + let id: UUID + let apiKey: String + } + + do { + let legacyModels = try JSONDecoder().decode([LegacyCustomCloudModel].self, from: data) + for model in legacyModels where !model.apiKey.isEmpty { + let keyIdentifier = customModelKeyIdentifier(for: model.id) + keychain.save(model.apiKey, forKey: keyIdentifier) + } + } catch { + logger.error("Failed to decode legacy custom models: \(error.localizedDescription)") + } + } + + // MARK: - Key Identifier Helpers + + /// Returns Keychain identifier for a provider (case-insensitive). + private func keychainIdentifier(forProvider provider: String) -> String { + let lowercased = provider.lowercased() + if let mapped = Self.providerToKeychainKey[lowercased] { + return mapped + } + return "\(lowercased)APIKey" + } + + /// Returns old UserDefaults key for provider (pre-Keychain format). + private func oldUserDefaultsKey(forProvider provider: String) -> String { + switch provider.lowercased() { + case "groq": + return "GROQAPIKey" + case "deepgram": + return "DeepgramAPIKey" + case "cerebras": + return "CerebrasAPIKey" + case "gemini": + return "GeminiAPIKey" + case "mistral": + return "MistralAPIKey" + case "elevenlabs": + return "ElevenLabsAPIKey" + case "soniox": + return "SonioxAPIKey" + case "openai": + return "OpenAIAPIKey" + case "anthropic": + return "AnthropicAPIKey" + case "openrouter": + return "OpenRouterAPIKey" + default: + return "\(provider)APIKey" + } + } + + /// Cleans up UserDefaults entries for a provider. + private func cleanupUserDefaultsForProvider(_ provider: String) { + userDefaults.removeObject(forKey: oldUserDefaultsKey(forProvider: provider)) + } + + /// Generates Keychain identifier for custom model API key. + private func customModelKeyIdentifier(for modelId: UUID) -> String { + "customModel_\(modelId.uuidString)_APIKey" + } +} diff --git a/VoiceInk/Services/CloudTranscription/CustomModelManager.swift b/VoiceInk/Services/CloudTranscription/CustomModelManager.swift index 41886f5..f62257c 100644 --- a/VoiceInk/Services/CloudTranscription/CustomModelManager.swift +++ b/VoiceInk/Services/CloudTranscription/CustomModelManager.swift @@ -25,6 +25,7 @@ class CustomModelManager: ObservableObject { func removeCustomModel(withId id: UUID) { customModels.removeAll { $0.id == id } saveCustomModels() + APIKeyManager.shared.deleteCustomModelAPIKey(forModelId: id) logger.info("Removed custom model with ID: \(id)") } diff --git a/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift index 8d0a4fc..d27cf62 100644 --- a/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift @@ -42,7 +42,7 @@ class DeepgramTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "DeepgramAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Deepgram"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift index f2ede32..2c24164 100644 --- a/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift @@ -6,7 +6,7 @@ class ElevenLabsTranscriptionService { private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService") func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { - guard let apiKey = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "ElevenLabs"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift index 543a773..80f79ab 100644 --- a/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift @@ -75,15 +75,15 @@ class GeminiTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "GeminiAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Gemini"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } - + let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent" guard let apiURL = URL(string: urlString) else { throw CloudTranscriptionError.dataEncodingError } - + return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name) } diff --git a/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift index daf3da5..42fbda3 100644 --- a/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift @@ -102,10 +102,10 @@ class GroqTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "GROQAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Groq"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } - + guard let apiURL = URL(string: "https://api.groq.com/openai/v1/audio/transcriptions") else { throw NSError(domain: "GroqTranscriptionService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"]) } diff --git a/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift index f98d02d..9979836 100644 --- a/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift @@ -6,8 +6,7 @@ class MistralTranscriptionService { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { logger.notice("Sending transcription request to Mistral for model: \(model.name)") - let apiKey = UserDefaults.standard.string(forKey: "MistralAPIKey") ?? "" - guard !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Mistral"), !apiKey.isEmpty else { logger.error("Mistral API key is missing.") throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift index 95cece6..f5a7647 100644 --- a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift @@ -24,7 +24,7 @@ class SonioxTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "SonioxAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Soniox"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } return APIConfig(apiKey: apiKey) diff --git a/VoiceInk/Services/KeychainService.swift b/VoiceInk/Services/KeychainService.swift new file mode 100644 index 0000000..8b7467b --- /dev/null +++ b/VoiceInk/Services/KeychainService.swift @@ -0,0 +1,117 @@ +import Foundation +import Security +import os + +/// Securely stores and retrieves API keys using Keychain with iCloud sync. +final class KeychainService { + static let shared = KeychainService() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "KeychainService") + private let accessGroup = "com.prakashjoshipax.VoiceInk" + private let service = "com.prakashjoshipax.VoiceInk" + + private init() {} + + // MARK: - Public API + + /// Saves a string value to Keychain. + @discardableResult + func save(_ value: String, forKey key: String, syncable: Bool = true) -> Bool { + guard let data = value.data(using: .utf8) else { + logger.error("Failed to convert value to data for key: \(key)") + return false + } + return save(data: data, forKey: key, syncable: syncable) + } + + /// Saves data to Keychain. + @discardableResult + func save(data: Data, forKey key: String, syncable: Bool = true) -> Bool { + // First, try to delete any existing item to avoid duplicates + delete(forKey: key, syncable: syncable) + + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecValueData as String] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + logger.info("Successfully saved keychain item for key: \(key)") + return true + } else { + logger.error("Failed to save keychain item for key: \(key), status: \(status)") + return false + } + } + + /// Retrieves a string value from Keychain. + func getString(forKey key: String, syncable: Bool = true) -> String? { + guard let data = getData(forKey: key, syncable: syncable) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + /// Retrieves data from Keychain. + func getData(forKey key: String, syncable: Bool = true) -> Data? { + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess { + return result as? Data + } else if status != errSecItemNotFound { + logger.error("Failed to retrieve keychain item for key: \(key), status: \(status)") + } + + return nil + } + + /// Deletes an item from Keychain. + @discardableResult + func delete(forKey key: String, syncable: Bool = true) -> Bool { + let query = baseQuery(forKey: key, syncable: syncable) + let status = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess || status == errSecItemNotFound { + if status == errSecSuccess { + logger.info("Successfully deleted keychain item for key: \(key)") + } + return true + } else { + logger.error("Failed to delete keychain item for key: \(key), status: \(status)") + return false + } + } + + /// Checks if a key exists in Keychain. + func exists(forKey key: String, syncable: Bool = true) -> Bool { + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecReturnData as String] = kCFBooleanFalse + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + // MARK: - Private Helpers + + /// Creates base Keychain query dictionary. + private func baseQuery(forKey key: String, syncable: Bool) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, + kSecUseDataProtectionKeychain as String: true + ] + + if syncable { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } + + return query + } +} diff --git a/VoiceInk/Views/AI Models/AddCustomModelView.swift b/VoiceInk/Views/AI Models/AddCustomModelView.swift index d2b87ee..c98635b 100644 --- a/VoiceInk/Views/AI Models/AddCustomModelView.swift +++ b/VoiceInk/Views/AI Models/AddCustomModelView.swift @@ -223,39 +223,35 @@ struct AddCustomModelCardView: View { } isSaving = true - - // Simulate a brief save operation for better UX + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if let editing = editingModel { - // Update existing model let updatedModel = CustomCloudModel( id: editing.id, name: generatedName, displayName: trimmedDisplayName, description: "Custom transcription model", apiEndpoint: trimmedApiEndpoint, - apiKey: trimmedApiKey, modelName: trimmedModelName, isMultilingual: isMultilingual ) + APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id) customModelManager.updateCustomModel(updatedModel) } else { - // Add new model let customModel = CustomCloudModel( name: generatedName, displayName: trimmedDisplayName, description: "Custom transcription model", apiEndpoint: trimmedApiEndpoint, - apiKey: trimmedApiKey, modelName: trimmedModelName, isMultilingual: isMultilingual ) + APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id) customModelManager.addCustomModel(customModel) } - + onModelAdded() - - // Reset form and collapse + withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) { isExpanded = false clearForm() diff --git a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift index 4db2b19..426c96d 100644 --- a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift +++ b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift @@ -21,10 +21,7 @@ struct CloudModelCardView: View { } private var isConfigured: Bool { - guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else { - return false - } - return !savedKey.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: providerKey) } private var providerKey: String { @@ -267,7 +264,7 @@ struct CloudModelCardView: View { } private func loadSavedAPIKey() { - if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: providerKey) { apiKey = savedKey verificationStatus = .success } @@ -306,10 +303,10 @@ struct CloudModelCardView: View { if isValid { self.verificationStatus = .success self.verificationError = nil - // Save the API key - UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey") + // Save the API key to Keychain + APIKeyManager.shared.saveAPIKey(self.apiKey, forProvider: self.providerKey) self.isConfiguredState = true - + // Collapse the configuration section after successful verification withAnimation(.easeInOut(duration: 0.3)) { self.isExpanded = false @@ -318,20 +315,17 @@ struct CloudModelCardView: View { self.verificationStatus = .failure self.verificationError = errorMessage } - - // Restore original provider - // aiService.selectedProvider = originalProvider // This line was removed as per the new_code } } } private func clearAPIKey() { - UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey") + APIKeyManager.shared.deleteAPIKey(forProvider: providerKey) apiKey = "" verificationStatus = .none verificationError = nil isConfiguredState = false - + // If this model is currently the default, clear it if isCurrent { Task { @@ -341,7 +335,7 @@ struct CloudModelCardView: View { } } } - + withAnimation(.easeInOut(duration: 0.3)) { isExpanded = false }