Merge pull request #475 from Beingpax/secure-keychain-storage
Secure keychain storage
This commit is contained in:
commit
8d06068145
@ -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") }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
}
|
||||
@ -224,22 +224,27 @@ 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
|
||||
} 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 +288,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 +520,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)
|
||||
}
|
||||
|
||||
|
||||
225
VoiceInk/Services/APIKeyManager.swift
Normal file
225
VoiceInk/Services/APIKeyManager.swift
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
115
VoiceInk/Services/KeychainService.swift
Normal file
115
VoiceInk/Services/KeychainService.swift
Normal file
@ -0,0 +1,115 @@
|
||||
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 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,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
|
||||
if syncable {
|
||||
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
125
VoiceInk/Services/LicenseManager.swift
Normal file
125
VoiceInk/Services/LicenseManager.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,58 +2,10 @@ import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
static let aiProviderApiKey = "VoiceInkAIProviderKey"
|
||||
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: - 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) }
|
||||
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
|
||||
@ -79,4 +31,4 @@ extension UserDefaults {
|
||||
get { bool(forKey: Keys.affiliatePromotionDismissed) }
|
||||
set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,39 +223,49 @@ 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
|
||||
)
|
||||
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 {
|
||||
// Add new model
|
||||
let customModel = CustomCloudModel(
|
||||
name: generatedName,
|
||||
displayName: trimmedDisplayName,
|
||||
description: "Custom transcription model",
|
||||
apiEndpoint: trimmedApiEndpoint,
|
||||
apiKey: trimmedApiKey,
|
||||
modelName: trimmedModelName,
|
||||
isMultilingual: isMultilingual
|
||||
)
|
||||
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()
|
||||
|
||||
// Reset form and collapse
|
||||
|
||||
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
|
||||
isExpanded = false
|
||||
clearForm()
|
||||
|
||||
@ -21,16 +21,13 @@ 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 {
|
||||
switch model.provider {
|
||||
case .groq:
|
||||
return "GROQ"
|
||||
return "Groq"
|
||||
case .elevenLabs:
|
||||
return "ElevenLabs"
|
||||
case .deepgram:
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user