|
@@ -0,0 +1,127 @@
|
|
|
|
|
+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)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|