Add StorageKeys, LegacyMigrationSourceKey, Value, ModernMigrationDestinationKey (+4 more)

This commit is contained in:
Matt Bruce 2026-01-14 17:29:10 -06:00
parent f6d0760b20
commit d7fe93bcf1
4 changed files with 167 additions and 1 deletions

View File

@ -45,6 +45,13 @@ struct ContentView: View {
.tabItem { .tabItem {
Label("Sync", systemImage: "arrow.triangle.2.circlepath") Label("Sync", systemImage: "arrow.triangle.2.circlepath")
} }
NavigationStack {
MigrationDemo()
}
.tabItem {
Label("Migration", systemImage: "sparkles")
}
} }
} }
} }

View File

@ -21,7 +21,9 @@ nonisolated struct AppStorageCatalog: StorageKeyCatalog {
.key(StorageKeys.SyncableSettingKey()), .key(StorageKeys.SyncableSettingKey()),
.key(StorageKeys.ExternalKeyMaterialKey()), .key(StorageKeys.ExternalKeyMaterialKey()),
.key(StorageKeys.AppGroupUserDefaultsKey()), .key(StorageKeys.AppGroupUserDefaultsKey()),
.key(StorageKeys.AppGroupUserProfileKey()) .key(StorageKeys.AppGroupUserProfileKey()),
.key(StorageKeys.LegacyMigrationSourceKey()),
.key(StorageKeys.ModernMigrationDestinationKey())
] ]
} }
} }

View File

@ -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<String> = .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<String> = .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())]
}
}
}

View File

@ -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
}
}
}