From 948033ac28c32d05c52170d7b510e33a21abd1e9 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 22:28:34 +0545 Subject: [PATCH 1/8] Migrate API key storage to Keychain with iCloud sync Move API keys from UserDefaults to secure Keychain storage. Add KeychainService and APIKeyManager for centralized key management. Enable iCloud Keychain sync for cross-device sharing between macOS and iOS. --- VoiceInk/Models/TranscriptionModel.swift | 45 +++- .../Services/AIEnhancement/AIService.swift | 18 +- VoiceInk/Services/APIKeyManager.swift | 225 ++++++++++++++++++ .../CustomModelManager.swift | 1 + .../DeepgramTranscriptionService.swift | 2 +- .../ElevenLabsTranscriptionService.swift | 2 +- .../GeminiTranscriptionService.swift | 6 +- .../GroqTranscriptionService.swift | 4 +- .../MistralTranscriptionService.swift | 3 +- .../SonioxTranscriptionService.swift | 2 +- VoiceInk/Services/KeychainService.swift | 117 +++++++++ .../Views/AI Models/AddCustomModelView.swift | 14 +- .../AI Models/CloudModelCardRowView.swift | 22 +- 13 files changed, 415 insertions(+), 46 deletions(-) create mode 100644 VoiceInk/Services/APIKeyManager.swift create mode 100644 VoiceInk/Services/KeychainService.swift 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 } From 8c1443f901b067a8e3d45c1637828d1e64bce126 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 22:32:22 +0545 Subject: [PATCH 2/8] Standardize Groq naming to proper case --- VoiceInk/Services/AIEnhancement/AIService.swift | 2 +- VoiceInk/Views/AI Models/CloudModelCardRowView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VoiceInk/Services/AIEnhancement/AIService.swift b/VoiceInk/Services/AIEnhancement/AIService.swift index dd0d833..697d0a8 100644 --- a/VoiceInk/Services/AIEnhancement/AIService.swift +++ b/VoiceInk/Services/AIEnhancement/AIService.swift @@ -2,7 +2,7 @@ import Foundation enum AIProvider: String, CaseIterable { case cerebras = "Cerebras" - case groq = "GROQ" + case groq = "Groq" case gemini = "Gemini" case anthropic = "Anthropic" case openAI = "OpenAI" diff --git a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift index 426c96d..6d7bd61 100644 --- a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift +++ b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift @@ -27,7 +27,7 @@ struct CloudModelCardView: View { private var providerKey: String { switch model.provider { case .groq: - return "GROQ" + return "Groq" case .elevenLabs: return "ElevenLabs" case .deepgram: From 50ed8c2e51a611d8f503acd3549841cba47cab79 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 22:33:55 +0545 Subject: [PATCH 3/8] Fix model queries to use Keychain for API key checks --- .../Whisper/WhisperState+ModelQueries.swift | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/VoiceInk/Whisper/WhisperState+ModelQueries.swift b/VoiceInk/Whisper/WhisperState+ModelQueries.swift index f634ea0..ff92be3 100644 --- a/VoiceInk/Whisper/WhisperState+ModelQueries.swift +++ b/VoiceInk/Whisper/WhisperState+ModelQueries.swift @@ -15,23 +15,17 @@ extension WhisperState { return false } case .groq: - let key = UserDefaults.standard.string(forKey: "GROQAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Groq") case .elevenLabs: - let key = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "ElevenLabs") case .deepgram: - let key = UserDefaults.standard.string(forKey: "DeepgramAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Deepgram") case .mistral: - let key = UserDefaults.standard.string(forKey: "MistralAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Mistral") case .gemini: - let key = UserDefaults.standard.string(forKey: "GeminiAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Gemini") case .soniox: - let key = UserDefaults.standard.string(forKey: "SonioxAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Soniox") case .custom: // Custom models are always usable since they contain their own API keys return true From eadf889a1587fe405ff128883be0cf7e59fc62a6 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 22:37:56 +0545 Subject: [PATCH 4/8] Remove unused aiProviderApiKey from UserDefaults --- VoiceInk/Services/UserDefaultsManager.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/VoiceInk/Services/UserDefaultsManager.swift b/VoiceInk/Services/UserDefaultsManager.swift index 88a9895..cadeee6 100644 --- a/VoiceInk/Services/UserDefaultsManager.swift +++ b/VoiceInk/Services/UserDefaultsManager.swift @@ -2,7 +2,6 @@ import Foundation extension UserDefaults { enum Keys { - static let aiProviderApiKey = "VoiceInkAIProviderKey" static let licenseKey = "VoiceInkLicense" static let trialStartDate = "VoiceInkTrialStartDate" static let audioInputMode = "audioInputMode" @@ -15,13 +14,7 @@ extension UserDefaults { static let trialStartDate = "VoiceInkTrialStartDate" } } - - // MARK: - AI Provider API Key - var aiProviderApiKey: String? { - get { string(forKey: Keys.aiProviderApiKey) } - set { setValue(newValue, forKey: Keys.aiProviderApiKey) } - } - + // MARK: - License Key var licenseKey: String? { get { string(forKey: Keys.licenseKey) } From aab096d252064195ab7c073e55f4e3b51afcdb10 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 23:06:46 +0545 Subject: [PATCH 5/8] Move license key and trial date from UserDefaults to Keychain --- VoiceInk/Models/LicenseViewModel.swift | 77 ++++++------ VoiceInk/Services/LicenseManager.swift | 125 ++++++++++++++++++++ VoiceInk/Services/SystemInfoService.swift | 6 +- VoiceInk/Services/UserDefaultsManager.swift | 43 +------ 4 files changed, 165 insertions(+), 86 deletions(-) create mode 100644 VoiceInk/Services/LicenseManager.swift diff --git a/VoiceInk/Models/LicenseViewModel.swift b/VoiceInk/Models/LicenseViewModel.swift index fdef606..e867d52 100644 --- a/VoiceInk/Models/LicenseViewModel.swift +++ b/VoiceInk/Models/LicenseViewModel.swift @@ -8,44 +8,45 @@ class LicenseViewModel: ObservableObject { case trialExpired case licensed } - + @Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial @Published var licenseKey: String = "" @Published var isValidating = false @Published var validationMessage: String? @Published private(set) var activationsLimit: Int = 0 - + private let trialPeriodDays = 7 private let polarService = PolarService() private let userDefaults = UserDefaults.standard - + private let licenseManager = LicenseManager.shared + init() { loadLicenseState() } - + func startTrial() { // Only set trial start date if it hasn't been set before - if userDefaults.trialStartDate == nil { - userDefaults.trialStartDate = Date() + if licenseManager.trialStartDate == nil { + licenseManager.trialStartDate = Date() licenseState = .trial(daysRemaining: trialPeriodDays) NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) } } - + private func loadLicenseState() { // Check for existing license key - if let licenseKey = userDefaults.licenseKey { - self.licenseKey = licenseKey - + if let storedLicenseKey = licenseManager.licenseKey { + self.licenseKey = storedLicenseKey + // If we have a license key, trust that it's licensed // Skip server validation on startup - if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { + if licenseManager.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { licenseState = .licensed activationsLimit = userDefaults.activationsLimit return } } - + // Check if this is first launch let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore") if !hasLaunchedBefore { @@ -54,11 +55,11 @@ class LicenseViewModel: ObservableObject { startTrial() return } - + // Only check trial if not licensed and not first launch - if let trialStartDate = userDefaults.trialStartDate { + if let trialStartDate = licenseManager.trialStartDate { let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0 - + if daysSinceTrialStart >= trialPeriodDays { licenseState = .trialExpired } else { @@ -104,13 +105,13 @@ class LicenseViewModel: ObservableObject { } // Store the license key - userDefaults.licenseKey = licenseKey - + licenseManager.licenseKey = licenseKey + // Handle based on whether activation is required if licenseCheck.requiresActivation { // If we already have an activation ID, validate with it - if let activationId = userDefaults.activationId { - let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId) + if let existingActivationId = licenseManager.activationId { + let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId) if isValid { // Existing activation is valid licenseState = .licensed @@ -120,23 +121,23 @@ class LicenseViewModel: ObservableObject { return } } - + // Need to create a new activation - let (activationId, limit) = try await polarService.activateLicenseKey(licenseKey) - + let (newActivationId, limit) = try await polarService.activateLicenseKey(licenseKey) + // Store activation details - userDefaults.activationId = activationId + licenseManager.activationId = newActivationId userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = limit userDefaults.activationsLimit = limit - + } else { // This license doesn't require activation (unlimited devices) - userDefaults.activationId = nil + licenseManager.activationId = nil userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = licenseCheck.activationsLimit ?? 0 userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0 - + // Update the license state for unlimited license licenseState = .licensed validationMessage = "License validated successfully!" @@ -154,12 +155,12 @@ class LicenseViewModel: ObservableObject { validationMessage = "Activation limit reached: \(details)" } catch LicenseError.activationNotRequired { // This is actually a success case for unlimited licenses - userDefaults.licenseKey = licenseKey - userDefaults.activationId = nil + licenseManager.licenseKey = licenseKey + licenseManager.activationId = nil userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = 0 userDefaults.activationsLimit = 0 - + licenseState = .licensed validationMessage = "License activated successfully!" NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) @@ -171,15 +172,14 @@ class LicenseViewModel: ObservableObject { } func removeLicense() { - // Remove both license key and trial data - userDefaults.licenseKey = nil - userDefaults.activationId = nil + // Remove all license data from Keychain + licenseManager.removeAll() + + // Reset UserDefaults flags userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") - userDefaults.trialStartDate = nil userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart - userDefaults.activationsLimit = 0 - + licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state licenseKey = "" validationMessage = nil @@ -190,13 +190,8 @@ class LicenseViewModel: ObservableObject { } -// Add UserDefaults extensions for storing activation ID +// UserDefaults extension for non-sensitive license settings extension UserDefaults { - var activationId: String? { - get { string(forKey: "VoiceInkActivationId") } - set { set(newValue, forKey: "VoiceInkActivationId") } - } - var activationsLimit: Int { get { integer(forKey: "VoiceInkActivationsLimit") } set { set(newValue, forKey: "VoiceInkActivationsLimit") } diff --git a/VoiceInk/Services/LicenseManager.swift b/VoiceInk/Services/LicenseManager.swift new file mode 100644 index 0000000..675736f --- /dev/null +++ b/VoiceInk/Services/LicenseManager.swift @@ -0,0 +1,125 @@ +import Foundation +import os + +/// Manages license data using secure Keychain storage (non-syncable, device-local). +final class LicenseManager { + static let shared = LicenseManager() + + private let keychain = KeychainService.shared + private let userDefaults = UserDefaults.standard + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "LicenseManager") + + private let licenseKeyIdentifier = "voiceink.license.key" + private let trialStartDateIdentifier = "voiceink.license.trialStartDate" + private let activationIdIdentifier = "voiceink.license.activationId" + private let migrationCompletedKey = "LicenseKeychainMigrationCompleted" + + private init() { + migrateFromUserDefaultsIfNeeded() + } + + // MARK: - License Key + + var licenseKey: String? { + get { keychain.getString(forKey: licenseKeyIdentifier, syncable: false) } + set { + if let value = newValue { + keychain.save(value, forKey: licenseKeyIdentifier, syncable: false) + } else { + keychain.delete(forKey: licenseKeyIdentifier, syncable: false) + } + } + } + + // MARK: - Trial Start Date + + var trialStartDate: Date? { + get { + guard let data = keychain.getData(forKey: trialStartDateIdentifier, syncable: false), + let timestamp = String(data: data, encoding: .utf8), + let timeInterval = Double(timestamp) else { + return nil + } + return Date(timeIntervalSince1970: timeInterval) + } + set { + if let date = newValue { + let timestamp = String(date.timeIntervalSince1970) + keychain.save(timestamp, forKey: trialStartDateIdentifier, syncable: false) + } else { + keychain.delete(forKey: trialStartDateIdentifier, syncable: false) + } + } + } + + // MARK: - Activation ID + + var activationId: String? { + get { keychain.getString(forKey: activationIdIdentifier, syncable: false) } + set { + if let value = newValue { + keychain.save(value, forKey: activationIdIdentifier, syncable: false) + } else { + keychain.delete(forKey: activationIdIdentifier, syncable: false) + } + } + } + + // MARK: - Migration + + private func migrateFromUserDefaultsIfNeeded() { + guard !userDefaults.bool(forKey: migrationCompletedKey) else { return } + + // Migrate license key + if let oldLicenseKey = userDefaults.string(forKey: "VoiceInkLicense"), !oldLicenseKey.isEmpty { + licenseKey = oldLicenseKey + userDefaults.removeObject(forKey: "VoiceInkLicense") + logger.info("Migrated license key to Keychain") + } + + // Migrate trial start date (from obfuscated storage) + if let oldTrialDate = getObfuscatedTrialStartDate() { + trialStartDate = oldTrialDate + clearObfuscatedTrialStartDate() + logger.info("Migrated trial start date to Keychain") + } + + // Migrate activation ID + if let oldActivationId = userDefaults.string(forKey: "VoiceInkActivationId"), !oldActivationId.isEmpty { + activationId = oldActivationId + userDefaults.removeObject(forKey: "VoiceInkActivationId") + logger.info("Migrated activation ID to Keychain") + } + + userDefaults.set(true, forKey: migrationCompletedKey) + logger.info("License migration completed") + } + + /// Reads the old obfuscated trial start date from UserDefaults. + private func getObfuscatedTrialStartDate() -> Date? { + let salt = Obfuscator.getDeviceIdentifier() + let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt) + + guard let obfuscatedValue = userDefaults.string(forKey: obfuscatedKey), + let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt), + let timestamp = Double(decodedValue) else { + return nil + } + + return Date(timeIntervalSince1970: timestamp) + } + + /// Clears the old obfuscated trial start date from UserDefaults. + private func clearObfuscatedTrialStartDate() { + let salt = Obfuscator.getDeviceIdentifier() + let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt) + userDefaults.removeObject(forKey: obfuscatedKey) + } + + /// Removes all license data (for license removal/reset). + func removeAll() { + licenseKey = nil + trialStartDate = nil + activationId = nil + } +} diff --git a/VoiceInk/Services/SystemInfoService.swift b/VoiceInk/Services/SystemInfoService.swift index 490c2b4..57d3ca2 100644 --- a/VoiceInk/Services/SystemInfoService.swift +++ b/VoiceInk/Services/SystemInfoService.swift @@ -188,11 +188,11 @@ class SystemInfoService { } private func getLicenseStatus() -> String { - let userDefaults = UserDefaults.standard + let licenseManager = LicenseManager.shared // Check for existing license key and activation - if let _ = userDefaults.licenseKey { - if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { + if licenseManager.licenseKey != nil { + if licenseManager.activationId != nil || !UserDefaults.standard.bool(forKey: "VoiceInkLicenseRequiresActivation") { return "Licensed (Pro)" } } diff --git a/VoiceInk/Services/UserDefaultsManager.swift b/VoiceInk/Services/UserDefaultsManager.swift index cadeee6..29aad14 100644 --- a/VoiceInk/Services/UserDefaultsManager.swift +++ b/VoiceInk/Services/UserDefaultsManager.swift @@ -2,51 +2,10 @@ import Foundation extension UserDefaults { enum Keys { - static let licenseKey = "VoiceInkLicense" - static let trialStartDate = "VoiceInkTrialStartDate" static let audioInputMode = "audioInputMode" static let selectedAudioDeviceUID = "selectedAudioDeviceUID" static let prioritizedDevices = "prioritizedDevices" static let affiliatePromotionDismissed = "VoiceInkAffiliatePromotionDismissed" - - // Obfuscated keys for license-related data - enum License { - static let trialStartDate = "VoiceInkTrialStartDate" - } - } - - // MARK: - License Key - var licenseKey: String? { - get { string(forKey: Keys.licenseKey) } - set { setValue(newValue, forKey: Keys.licenseKey) } - } - - // MARK: - Trial Start Date (Obfuscated) - var trialStartDate: Date? { - get { - let salt = Obfuscator.getDeviceIdentifier() - let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt) - - guard let obfuscatedValue = string(forKey: obfuscatedKey), - let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt), - let timestamp = Double(decodedValue) else { - return nil - } - - return Date(timeIntervalSince1970: timestamp) - } - set { - let salt = Obfuscator.getDeviceIdentifier() - let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt) - - if let date = newValue { - let timestamp = String(date.timeIntervalSince1970) - let obfuscatedValue = Obfuscator.encode(timestamp, salt: salt) - setValue(obfuscatedValue, forKey: obfuscatedKey) - } else { - removeObject(forKey: obfuscatedKey) - } - } } // MARK: - Audio Input Mode @@ -72,4 +31,4 @@ extension UserDefaults { get { bool(forKey: Keys.affiliatePromotionDismissed) } set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) } } -} \ No newline at end of file +} From c4167e9e9021fdf527c1337237951c265d73d23e Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 23:19:30 +0545 Subject: [PATCH 6/8] Fix keychain access errors by removing explicit access group that mismatched entitlements --- VoiceInk/Services/KeychainService.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/VoiceInk/Services/KeychainService.swift b/VoiceInk/Services/KeychainService.swift index 8b7467b..cc74734 100644 --- a/VoiceInk/Services/KeychainService.swift +++ b/VoiceInk/Services/KeychainService.swift @@ -7,7 +7,6 @@ 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() {} @@ -104,7 +103,6 @@ final class KeychainService { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: accessGroup, kSecUseDataProtectionKeychain as String: true ] From 336dd1441b9b77b7d21aea68b76f588a99812fa0 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 6 Jan 2026 14:02:40 +0545 Subject: [PATCH 7/8] Fix ignored keychain save result to prevent creating custom models without API keys --- .../Views/AI Models/AddCustomModelView.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/VoiceInk/Views/AI Models/AddCustomModelView.swift b/VoiceInk/Views/AI Models/AddCustomModelView.swift index c98635b..a78551e 100644 --- a/VoiceInk/Views/AI Models/AddCustomModelView.swift +++ b/VoiceInk/Views/AI Models/AddCustomModelView.swift @@ -235,8 +235,15 @@ struct AddCustomModelCardView: View { modelName: trimmedModelName, isMultilingual: isMultilingual ) - APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id) - customModelManager.updateCustomModel(updatedModel) + + if APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id) { + customModelManager.updateCustomModel(updatedModel) + } else { + validationErrors = ["Failed to securely save API Key to Keychain. Please check your system settings or try again."] + showingAlert = true + isSaving = false + return + } } else { let customModel = CustomCloudModel( name: generatedName, @@ -246,8 +253,15 @@ struct AddCustomModelCardView: View { modelName: trimmedModelName, isMultilingual: isMultilingual ) - APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id) - customModelManager.addCustomModel(customModel) + + if APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id) { + customModelManager.addCustomModel(customModel) + } else { + validationErrors = ["Failed to securely save API Key to Keychain. Please check your system settings or try again."] + showingAlert = true + isSaving = false + return + } } onModelAdded() From 69c5d4abd1e11658ebce18a3d9ee59b7007ff69d Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 6 Jan 2026 14:20:33 +0545 Subject: [PATCH 8/8] Migrate legacy "GROQ" raw value to "Groq" --- VoiceInk/Services/AIEnhancement/AIService.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/VoiceInk/Services/AIEnhancement/AIService.swift b/VoiceInk/Services/AIEnhancement/AIService.swift index 697d0a8..3c69418 100644 --- a/VoiceInk/Services/AIEnhancement/AIService.swift +++ b/VoiceInk/Services/AIEnhancement/AIService.swift @@ -224,6 +224,11 @@ class AIService: ObservableObject { } init() { + // Migrate legacy "GROQ" raw value to "Groq" + if userDefaults.string(forKey: "selectedAIProvider") == "GROQ" { + userDefaults.set("Groq", forKey: "selectedAIProvider") + } + if let savedProvider = userDefaults.string(forKey: "selectedAIProvider"), let provider = AIProvider(rawValue: savedProvider) { self.selectedProvider = provider