diff --git a/SecureStorageSample/ContentView.swift b/SecureStorageSample/ContentView.swift index a0e130d..5fed85d 100644 --- a/SecureStorageSample/ContentView.swift +++ b/SecureStorageSample/ContentView.swift @@ -45,6 +45,13 @@ struct ContentView: View { .tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") } + + NavigationStack { + MigrationDemo() + } + .tabItem { + Label("Migration", systemImage: "sparkles") + } } } } diff --git a/SecureStorageSample/Services/AppStorageCatalog.swift b/SecureStorageSample/Services/AppStorageCatalog.swift index 8bcb8a0..f9b4c78 100644 --- a/SecureStorageSample/Services/AppStorageCatalog.swift +++ b/SecureStorageSample/Services/AppStorageCatalog.swift @@ -21,7 +21,9 @@ nonisolated struct AppStorageCatalog: StorageKeyCatalog { .key(StorageKeys.SyncableSettingKey()), .key(StorageKeys.ExternalKeyMaterialKey()), .key(StorageKeys.AppGroupUserDefaultsKey()), - .key(StorageKeys.AppGroupUserProfileKey()) + .key(StorageKeys.AppGroupUserProfileKey()), + .key(StorageKeys.LegacyMigrationSourceKey()), + .key(StorageKeys.ModernMigrationDestinationKey()) ] } } diff --git a/SecureStorageSample/StorageKeys/Migration/MigrationKeys.swift b/SecureStorageSample/StorageKeys/Migration/MigrationKeys.swift new file mode 100644 index 0000000..9e8e01c --- /dev/null +++ b/SecureStorageSample/StorageKeys/Migration/MigrationKeys.swift @@ -0,0 +1,40 @@ +import Foundation +import LocalData + +extension StorageKeys { + /// The legacy key where data starts (in UserDefaults) + struct LegacyMigrationSourceKey: StorageKey { + typealias Value = String + + let name = "legacy_user_id" + let domain: StorageDomain = .userDefaults(suite: nil) + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "MigrationDemo" + let description = "Legacy key in UserDefaults from an older version of the app." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } + + /// The modern key where data should end up (in Keychain) + struct ModernMigrationDestinationKey: StorageKey { + typealias Value = String + + let name = "secure_user_id" + let domain: StorageDomain = .keychain(service: "com.mbrucedogs.securestorage") + let security: SecurityPolicy = .keychain( + accessibility: .afterFirstUnlock, + accessControl: .userPresence + ) + let serializer: Serializer = .json + let owner = "MigrationDemo" + let description = "Modern key in Keychain with biometric security." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + + // Define the migration path + var migrationSources: [AnyStorageKey] { + [AnyStorageKey(LegacyMigrationSourceKey())] + } + } +} diff --git a/SecureStorageSample/Views/MigrationDemo.swift b/SecureStorageSample/Views/MigrationDemo.swift new file mode 100644 index 0000000..9b83980 --- /dev/null +++ b/SecureStorageSample/Views/MigrationDemo.swift @@ -0,0 +1,117 @@ +import SwiftUI +import LocalData + +@MainActor +struct MigrationDemo: View { + @State private var legacyValue = "" + @State private var modernValue = "" + @State private var statusMessage = "" + @State private var isLoading = false + + var body: some View { + Form { + Section("The Scenario") { + Text("Imagine you have an old version of the app that stored a User ID in plain UserDefaults. Now, you want to move it to the secure Keychain automatically without the user noticing.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Step 1: Setup Legacy Data") { + Text("First, save a value to the 'legacy' key in UserDefaults.") + .font(.caption) + + TextField("Legacy Value", text: $legacyValue) + .textFieldStyle(.roundedBorder) + + Button(action: saveToLegacy) { + Label("Save to Legacy (UserDefaults)", systemImage: "arrow.down.doc") + } + .disabled(legacyValue.isEmpty || isLoading) + } + + Section("Step 2: Trigger Migration") { + Text("Now, attempt to load from the 'modern' Keychain key. It will automatically check the legacy key, move the data, and delete the old record.") + .font(.caption) + + Button(action: loadFromModern) { + Label("Load from Modern (Keychain)", systemImage: "sparkles") + } + .disabled(isLoading) + + if !modernValue.isEmpty { + LabeledContent("Migrated Value", value: modernValue) + .foregroundStyle(.green) + .bold() + } + } + + Section("Step 3: Verify Cleanup") { + Text("Check if the data was actually removed from UserDefaults after migration.") + .font(.caption) + + Button(action: checkLegacyExists) { + Label("Check Legacy Exists?", systemImage: "magnifyingglass") + } + .disabled(isLoading) + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : .blue) + } + } + } + .navigationTitle("Data Migration") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Actions + + private func saveToLegacy() { + isLoading = true + Task { + do { + let key = StorageKeys.LegacyMigrationSourceKey() + try await StorageRouter.shared.set(legacyValue, for: key) + statusMessage = "✓ Saved '\(legacyValue)' to legacy UserDefaults" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadFromModern() { + isLoading = true + statusMessage = "Retrieving from Modern..." + Task { + do { + let key = StorageKeys.ModernMigrationDestinationKey() + let value = try await StorageRouter.shared.get(key) + modernValue = value + statusMessage = "✓ Success! Data migrated from UserDefaults to Keychain." + } catch StorageError.notFound { + statusMessage = "Modern key is empty (and no legacy data found)." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func checkLegacyExists() { + isLoading = true + Task { + do { + let key = StorageKeys.LegacyMigrationSourceKey() + let exists = try await StorageRouter.shared.exists(key) + statusMessage = exists ? "⚠️ Legacy data STILL EXISTS in UserDefaults!" : "✅ Legacy data was successfully DELETED." + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +}