| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127 |
- import Foundation
- // MARK: - Protocols for Dependency Injection
- /// Abstraction over Keychain storage for the API key.
- /// Production: KeychainService conforms. Tests: InMemoryKeyStore.
- protocol KeyStoreProtocol {
- func save(_ key: String) throws
- func load() -> String?
- func delete()
- }
- /// Abstraction over UserDefaults for reading/removing the legacy API key.
- /// Production: UserDefaultsStore conforms. Tests: InMemoryDefaultsStore.
- protocol DefaultsStoreProtocol {
- func string(forKey key: String) -> String?
- func removeObject(forKey key: String)
- }
- // MARK: - Production Store Adapters
- /// Adapts KeychainService (static enum) to KeyStoreProtocol.
- struct KeychainKeyStore: KeyStoreProtocol {
- func save(_ key: String) throws {
- try KeychainService.saveAPIKey(key)
- }
- func load() -> String? {
- KeychainService.loadAPIKey()
- }
- func delete() {
- KeychainService.deleteAPIKey()
- }
- }
- /// Adapts UserDefaults to DefaultsStoreProtocol.
- struct UserDefaultsStore: DefaultsStoreProtocol {
- private let defaults: UserDefaults
- init(defaults: UserDefaults = .standard) {
- self.defaults = defaults
- }
- func string(forKey key: String) -> String? {
- defaults.string(forKey: key)
- }
- func removeObject(forKey key: String) {
- defaults.removeObject(forKey: key)
- }
- }
- // MARK: - ChadMusicCredentials
- /// Single source of truth for the Chad Music API key.
- /// Reads from Keychain. On first access, migrates any existing
- /// UserDefaults value to Keychain and deletes the plaintext copy.
- @MainActor
- final class ChadMusicCredentials {
- /// Shared singleton using real Keychain + UserDefaults for production.
- static let shared = ChadMusicCredentials()
- private static let userDefaultsKey = "chadMusic.apiKey"
- private let keyStore: any KeyStoreProtocol
- private let defaultsStore: any DefaultsStoreProtocol
- private var hasMigrated = false
- /// DI initializer — tests inject in-memory mocks.
- init(
- keyStore: any KeyStoreProtocol = KeychainKeyStore(),
- defaultsStore: any DefaultsStoreProtocol = UserDefaultsStore()
- ) {
- self.keyStore = keyStore
- self.defaultsStore = defaultsStore
- }
- /// The current API key, or nil if not set.
- /// First call triggers one-time migration from UserDefaults → Keychain.
- var apiKey: String? {
- migrateIfNeeded()
- let key = keyStore.load()
- return (key?.isEmpty ?? true) ? nil : key
- }
- /// Whether an API key is currently stored.
- var hasKey: Bool {
- apiKey != nil
- }
- /// Save a new API key to Keychain.
- func save(_ key: String) throws {
- try keyStore.save(key)
- }
- /// Delete the API key from Keychain.
- func delete() {
- keyStore.delete()
- }
- // MARK: - Migration
- /// One-time migration: if UserDefaults has a key, move it to Keychain.
- private func migrateIfNeeded() {
- guard !hasMigrated else { return }
- hasMigrated = true
- guard let existingKey = defaultsStore.string(forKey: Self.userDefaultsKey),
- !existingKey.isEmpty else { return }
- // Only migrate if Keychain is empty (don't overwrite a Keychain value)
- if keyStore.load() == nil {
- do {
- try keyStore.save(existingKey)
- defaultsStore.removeObject(forKey: Self.userDefaultsKey)
- } catch {
- // Keychain denied — leave UserDefaults in place as fallback.
- print("[ChadMusicCredentials] Migration failed: \(error). Leaving UserDefaults in place.")
- }
- } else {
- // Keychain already has a value — just delete the plaintext copy
- defaultsStore.removeObject(forKey: Self.userDefaultsKey)
- }
- }
- }
|