Compare commits

..

6 Commits

Author SHA1 Message Date
04b44dcaad Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-16 14:35:12 -06:00
0a28f55229 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-16 14:30:07 -06:00
d2bf6004e1 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-16 14:09:19 -06:00
e13027356d Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-16 13:54:18 -06:00
7219c69b26 migration
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-01-16 13:48:06 -06:00
8d591883d5 migration
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-01-16 13:47:24 -06:00
26 changed files with 677 additions and 70 deletions

View File

@ -6,5 +6,6 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES
INFOPLIST_KEY_BaseBundleID = $(BASE_BUNDLE_ID) INFOPLIST_KEY_BaseBundleID = $(BASE_BUNDLE_ID)
INFOPLIST_KEY_TeamID = $(TEAM_ID) INFOPLIST_KEY_TeamID = $(TEAM_ID)
INFOPLIST_KEY_AppGroupID = $(APP_GROUP_ID) INFOPLIST_KEY_AppGroupID = $(APP_GROUP_ID)
INFOPLIST_KEY_NSFaceIDUsageDescription = SecureStorage uses Face ID to unlock protected Keychain items.
CODE_SIGN_ENTITLEMENTS = SecureStorageSample/SecureStorageSample.entitlements CODE_SIGN_ENTITLEMENTS = SecureStorageSample/SecureStorageSample.entitlements

View File

@ -42,7 +42,7 @@ SharedPackage/
└── Models/ └── Models/
└── UserProfile.swift └── UserProfile.swift
SecureStorageSample/ SecureStorageSample/
├── ContentView.swift # Tabbed navigation ├── ContentView.swift # List-based navigation
├── Models/ ├── Models/
│ ├── Credential.swift │ ├── Credential.swift
│ └── SampleLocationData.swift │ └── SampleLocationData.swift
@ -112,6 +112,7 @@ The app demonstrates various storage configurations:
### Encrypted Storage ### Encrypted Storage
- AES-256-GCM or ChaCha20-Poly1305 encryption - AES-256-GCM or ChaCha20-Poly1305 encryption
- PBKDF2 or HKDF key derivation - PBKDF2 or HKDF key derivation
- PBKDF2 iteration count must remain consistent or existing data will not decrypt
- Complete file protection - Complete file protection
- External key material example via `KeyMaterialProviding` - External key material example via `KeyMaterialProviding`
- Global encryption configuration (Keychain service/account) in app `init` - Global encryption configuration (Keychain service/account) in app `init`
@ -122,8 +123,11 @@ The app demonstrates various storage configurations:
- Global sync configuration (max file size) in app `init` - Global sync configuration (max file size) in app `init`
### Data Migration ### Data Migration
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access. - **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access using protocol-based migration.
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.migrate(for:)`. - **Transforming**: Converts a legacy full-name string into a structured `ProfileName`.
- **Aggregating**: Combines legacy notification + theme settings into `UnifiedSettings`.
- **Conditional**: Migrates app mode only when the version rule is met.
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.forceMigration(for:)`.
- **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`. - **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`.
## Global Configuration ## Global Configuration

View File

@ -8,49 +8,85 @@
import SwiftUI import SwiftUI
import LocalData import LocalData
enum DemoDestination: Hashable, CaseIterable {
case userDefaults
case keychain
case files
case encrypted
case sync
case migration
var view: some View {
switch self {
case .userDefaults:
return AnyView(UserDefaultsDemo())
case .keychain:
return AnyView(KeychainDemo())
case .files:
return AnyView(FileSystemDemo())
case .encrypted:
return AnyView(EncryptedStorageDemo())
case .sync:
return AnyView(PlatformSyncDemo())
case .migration:
return AnyView(MigrationHubView())
}
}
var title: String {
switch self {
case .userDefaults:
return "UserDefaults"
case .keychain:
return "Keychain"
case .files:
return "File Storage"
case .encrypted:
return "Encrypted Storage"
case .sync:
return "Platform & Sync"
case .migration:
return "Migrations"
}
}
var systemImage: String {
switch self {
case .userDefaults:
return "gearshape.fill"
case .keychain:
return "lock.fill"
case .files:
return "doc.fill"
case .encrypted:
return "lock.shield.fill"
case .sync:
return "arrow.triangle.2.circlepath"
case .migration:
return "sparkles"
}
}
}
struct ContentView: View { struct ContentView: View {
var body: some View { var body: some View {
TabView { NavigationStack {
NavigationStack { List {
UserDefaultsDemo() Section("Storage Demos") {
ForEach(DemoDestination.allCases, id: \.self) { demo in
NavigationLink(value: demo) {
Label(demo.title, systemImage: demo.systemImage)
}
}
}
} }
.tabItem { .scrollIndicators(.hidden)
Label("Defaults", systemImage: "gearshape.fill") .navigationTitle("Secure Storage")
} .navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: DemoDestination.self) { destination in
NavigationStack { return destination
KeychainDemo() .view
} .navigationTitle(destination.title)
.tabItem {
Label("Keychain", systemImage: "lock.fill")
}
NavigationStack {
FileSystemDemo()
}
.tabItem {
Label("Files", systemImage: "doc.fill")
}
NavigationStack {
EncryptedStorageDemo()
}
.tabItem {
Label("Encrypted", systemImage: "lock.shield.fill")
}
NavigationStack {
PlatformSyncDemo()
}
.tabItem {
Label("Sync", systemImage: "arrow.triangle.2.circlepath")
}
NavigationStack {
MigrationDemo()
}
.tabItem {
Label("Migration", systemImage: "sparkles")
} }
} }
} }

View File

@ -0,0 +1,23 @@
import SwiftUI
enum Design {
enum Spacing {
static let xSmall: CGFloat = 4
static let small: CGFloat = 8
static let medium: CGFloat = 16
static let large: CGFloat = 24
}
}
extension Color {
enum Status {
static let success = Color.green
static let info = Color.blue
static let warning = Color.orange
static let error = Color.red
}
enum Text {
static let secondary = Color.secondary
}
}

View File

@ -0,0 +1,6 @@
import Foundation
nonisolated struct ProfileName: Codable, Sendable {
let firstName: String
let lastName: String
}

View File

@ -0,0 +1,6 @@
import Foundation
nonisolated struct UnifiedSettings: Codable, Sendable {
let notificationsEnabled: Bool
let theme: String
}

View File

@ -48,7 +48,7 @@ struct SecureStorageSampleApp: App {
await StorageRouter.shared.updateStorageConfiguration(storageConfig) await StorageRouter.shared.updateStorageConfiguration(storageConfig)
do { do {
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true) try await StorageRouter.shared.registerCatalog(AppStorageCatalog(), migrateImmediately: true)
} catch { } catch {
assertionFailure("Storage catalog registration failed: \(error)") assertionFailure("Storage catalog registration failed: \(error)")
} }
@ -58,7 +58,7 @@ struct SecureStorageSampleApp: App {
) )
} }
#if DEBUG #if DEBUG
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) let report = StorageAuditReport.renderText(AppStorageCatalog())
print(report) print(report)
#endif #endif
} }

View File

@ -3,8 +3,8 @@ import LocalData
import SharedKit import SharedKit
nonisolated struct AppStorageCatalog: StorageKeyCatalog { struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[ [
.key(StorageKeys.AppVersionKey()), .key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey()), .key(StorageKeys.UserPreferencesKey()),
@ -23,7 +23,14 @@ nonisolated struct AppStorageCatalog: StorageKeyCatalog {
.key(StorageKeys.AppGroupUserDefaultsKey()), .key(StorageKeys.AppGroupUserDefaultsKey()),
.key(StorageKeys.AppGroupUserProfileKey()), .key(StorageKeys.AppGroupUserProfileKey()),
.key(StorageKeys.LegacyMigrationSourceKey()), .key(StorageKeys.LegacyMigrationSourceKey()),
.key(StorageKeys.ModernMigrationDestinationKey()) .key(StorageKeys.ModernMigrationDestinationKey()),
.key(StorageKeys.LegacyProfileNameKey()),
.key(StorageKeys.ModernProfileNameKey()),
.key(StorageKeys.LegacyNotificationSettingKey()),
.key(StorageKeys.LegacyThemeSettingKey()),
.key(StorageKeys.ModernUnifiedSettingsKey()),
.key(StorageKeys.LegacyAppModeKey()),
.key(StorageKeys.ModernAppModeKey())
] ]
} }
} }

View File

@ -15,7 +15,7 @@ extension StorageKeys {
let serializer: Serializer<String> = .json let serializer: Serializer<String> = .json
let owner = "SampleApp" let owner = "SampleApp"
let description = "Stores a shared setting readable by app extensions." let description = "Stores a shared setting readable by app extensions."
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
} }
} }

View File

@ -16,7 +16,7 @@ extension StorageKeys {
let serializer: Serializer<UserProfile> = .json let serializer: Serializer<UserProfile> = .json
let owner = "SampleApp" let owner = "SampleApp"
let description = "Stores a profile shared between the app and extensions." let description = "Stores a profile shared between the app and extensions."
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
init(directory: FileDirectory = .documents) { init(directory: FileDirectory = .documents) {

View File

@ -17,6 +17,7 @@ extension StorageKeys {
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
init(iterations: Int = 10_000) { init(iterations: Int = 10_000) {
// NOTE: PBKDF2 iterations must remain stable for existing data; changing this breaks decryption.
self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations))) self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations)))
} }
} }

View File

@ -0,0 +1,73 @@
import Foundation
import LocalData
extension StorageKeys {
struct LegacyNotificationSettingKey: StorageKey {
typealias Value = Bool
let name = "legacy_notification_setting"
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<Bool> = .json
let owner = "MigrationDemo"
let description = "Legacy notification setting stored as Bool."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
struct LegacyThemeSettingKey: StorageKey {
typealias Value = String
let name = "legacy_theme_setting"
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner = "MigrationDemo"
let description = "Legacy theme setting stored as a string."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
struct ModernUnifiedSettingsKey: StorageKey {
typealias Value = UnifiedSettings
let name = "modern_unified_settings"
let domain: StorageDomain = .fileSystem(directory: .documents)
let security: SecurityPolicy = .none
let serializer: Serializer<UnifiedSettings> = .json
let owner = "MigrationDemo"
let description = "Modern unified settings aggregated from legacy keys."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
var migration: AnyStorageMigration? {
let sources: [AnyStorageKey] = [
.key(StorageKeys.LegacyNotificationSettingKey()),
.key(StorageKeys.LegacyThemeSettingKey())
]
return AnyStorageMigration(
DefaultAggregatingMigration(
destinationKey: self,
sourceKeys: sources
) { sources in
var notificationsEnabled = false
var theme = "system"
for source in sources {
if let boolValue = source.value as? Bool {
notificationsEnabled = boolValue
} else if let stringValue = source.value as? String {
theme = stringValue
}
}
return UnifiedSettings(
notificationsEnabled: notificationsEnabled,
theme: theme
)
}
)
}
}
}

View File

@ -0,0 +1,49 @@
import Foundation
import LocalData
extension StorageKeys {
struct LegacyAppModeKey: StorageKey {
typealias Value = String
let name = "legacy_app_mode"
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner = "MigrationDemo"
let description = "Legacy app mode stored in UserDefaults."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
struct ModernAppModeKey: StorageKey {
typealias Value = String
let name = "modern_app_mode"
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 app mode with conditional migration."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
var migration: AnyStorageMigration? {
let destination = self
let legacy = StorageKeys.LegacyAppModeKey()
let fallback = AnyStorageMigration(
SimpleLegacyMigration(destinationKey: destination, sourceKey: .key(legacy))
)
return AnyStorageMigration(
AppVersionConditionalMigration(
destinationKey: destination,
minAppVersion: "99.0",
fallbackMigration: fallback
)
)
}
}
}

View File

@ -31,10 +31,14 @@ extension StorageKeys {
let description = "Modern key in Keychain with biometric security." let description = "Modern key in Keychain with biometric security."
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
// Define the migration path var migration: AnyStorageMigration? {
var migrationSources: [AnyStorageKey] { AnyStorageMigration(
[AnyStorageKey(LegacyMigrationSourceKey())] SimpleLegacyMigration(
destinationKey: self,
sourceKey: .key(LegacyMigrationSourceKey())
)
)
} }
} }
} }

View File

@ -0,0 +1,48 @@
import Foundation
import LocalData
extension StorageKeys {
struct LegacyProfileNameKey: StorageKey {
typealias Value = String
let name = "legacy_profile_name"
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner = "MigrationDemo"
let description = "Legacy profile name stored as a single string."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
struct ModernProfileNameKey: StorageKey {
typealias Value = ProfileName
let name = "modern_profile_name"
let domain: StorageDomain = .keychain(service: "com.mbrucedogs.securestorage")
let security: SecurityPolicy = .keychain(
accessibility: .afterFirstUnlock,
accessControl: .userPresence
)
let serializer: Serializer<ProfileName> = .json
let owner = "MigrationDemo"
let description = "Modern profile name stored as structured data."
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
var migration: AnyStorageMigration? {
let sourceKey = StorageKeys.LegacyProfileNameKey()
return AnyStorageMigration(
DefaultTransformingMigration(
destinationKey: self,
sourceKey: sourceKey
) { value in
let parts = value.split(separator: " ", maxSplits: 1).map(String.init)
let firstName = parts.first ?? ""
let lastName = parts.count > 1 ? parts[1] : ""
return ProfileName(firstName: firstName, lastName: lastName)
}
)
}
}
}

View File

@ -0,0 +1,103 @@
import SwiftUI
import LocalData
@MainActor
struct AggregatingMigrationDemo: View {
@State private var notificationsEnabled = false
@State private var theme = ""
@State private var modernValue = ""
@State private var statusMessage = ""
@State private var isLoading = false
var body: some View {
Form {
Section("The Scenario") {
Text("Two legacy settings are aggregated into a single unified settings model during migration.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Section("Step 1: Setup Legacy Data") {
Toggle("Notifications Enabled", isOn: $notificationsEnabled)
TextField("Theme", text: $theme)
.textFieldStyle(.roundedBorder)
Button(action: saveToLegacy) {
Label("Save Legacy Settings", systemImage: "tray.and.arrow.down")
}
.disabled(theme.isEmpty || isLoading)
}
Section("Step 2: Trigger Migration") {
Text("Load the modern key to aggregate legacy values into unified settings.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: loadFromModern) {
Label("Load Unified Settings", systemImage: "sparkles")
}
.disabled(isLoading)
if !modernValue.isEmpty {
LabeledContent("Unified Settings", value: modernValue)
.foregroundStyle(Color.Status.success)
.bold()
}
}
if !statusMessage.isEmpty {
Section {
Text(statusMessage)
.font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
}
}
}
.navigationTitle("Aggregating Migration")
.navigationBarTitleDisplayMode(.inline)
}
private func saveToLegacy() {
isLoading = true
Task {
do {
try await StorageRouter.shared.set(
notificationsEnabled,
for: StorageKeys.LegacyNotificationSettingKey()
)
try await StorageRouter.shared.set(
theme,
for: StorageKeys.LegacyThemeSettingKey()
)
statusMessage = "✓ Saved legacy settings"
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
private func loadFromModern() {
isLoading = true
statusMessage = "Loading unified settings..."
Task {
do {
let value = try await StorageRouter.shared.get(StorageKeys.ModernUnifiedSettingsKey())
modernValue = format(value)
statusMessage = "✓ Migration complete."
} catch StorageError.notFound {
statusMessage = "Modern key is empty (and no legacy data found)."
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
private func format(_ value: UnifiedSettings) -> String {
let notificationsText = value.notificationsEnabled ? "On" : "Off"
let themeText = value.theme.isEmpty ? "system" : value.theme
return "Notifications: \(notificationsText), Theme: \(themeText)"
}
}

View File

@ -0,0 +1,93 @@
import SwiftUI
import LocalData
@MainActor
struct ConditionalMigrationDemo: 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("A conditional migration only runs when the app version is below a threshold.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Section("Step 1: Setup Legacy Data") {
Text("Save a legacy app mode string that will migrate only if the version condition is met.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
TextField("Legacy App Mode", text: $legacyValue)
.textFieldStyle(.roundedBorder)
Button(action: saveToLegacy) {
Label("Save Legacy Mode", systemImage: "square.and.arrow.down")
}
.disabled(legacyValue.isEmpty || isLoading)
}
Section("Step 2: Trigger Conditional Migration") {
Text("Load the modern key to run the conditional migration.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: loadFromModern) {
Label("Load Modern Mode", systemImage: "sparkles")
}
.disabled(isLoading)
if !modernValue.isEmpty {
LabeledContent("Migrated Mode", value: modernValue)
.foregroundStyle(Color.Status.success)
.bold()
}
}
if !statusMessage.isEmpty {
Section {
Text(statusMessage)
.font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
}
}
}
.navigationTitle("Conditional Migration")
.navigationBarTitleDisplayMode(.inline)
}
private func saveToLegacy() {
isLoading = true
Task {
do {
let key = StorageKeys.LegacyAppModeKey()
try await StorageRouter.shared.set(legacyValue, for: key)
statusMessage = "✓ Saved legacy app mode"
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
private func loadFromModern() {
isLoading = true
statusMessage = "Loading modern mode..."
Task {
do {
let key = StorageKeys.ModernAppModeKey()
let value = try await StorageRouter.shared.get(key)
modernValue = value
statusMessage = "✓ Conditional migration complete."
} catch StorageError.notFound {
statusMessage = "Modern key is empty (and no legacy data found)."
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
}

View File

@ -37,6 +37,9 @@ struct EncryptedStorageDemo: View {
Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000) Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000)
.font(.caption) .font(.caption)
IterationWarningView()
.padding(.top, Design.Spacing.xSmall)
Text("Higher iterations = more secure but slower") Text("Higher iterations = more secure but slower")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -113,7 +116,6 @@ struct EncryptedStorageDemo: View {
LabeledContent("Platform", value: "Phone Only") LabeledContent("Platform", value: "Phone Only")
} }
} }
.navigationTitle("Encrypted Storage")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {
@ -211,3 +213,11 @@ struct EncryptedStorageDemo: View {
EncryptedStorageDemo() EncryptedStorageDemo()
} }
} }
private struct IterationWarningView: View {
var body: some View {
Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.")
.font(.caption2)
.foregroundStyle(Color.Status.warning)
}
}

View File

@ -83,6 +83,7 @@ struct FileSystemDemo: View {
.disabled(isLoading) .disabled(isLoading)
} }
#if os(iOS)
Section("App Group Storage") { Section("App Group Storage") {
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.") Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
.font(.caption) .font(.caption)
@ -113,6 +114,7 @@ struct FileSystemDemo: View {
.foregroundStyle(.red) .foregroundStyle(.red)
.disabled(isLoading) .disabled(isLoading)
} }
#endif
if let profile = storedProfile { if let profile = storedProfile {
Section("Retrieved Profile") { Section("Retrieved Profile") {
@ -155,7 +157,6 @@ struct FileSystemDemo: View {
LabeledContent("Platform", value: "Phone + Watch Sync") LabeledContent("Platform", value: "Phone + Watch Sync")
} }
} }
.navigationTitle("File System")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {

View File

@ -102,7 +102,6 @@ struct KeychainDemo: View {
LabeledContent("Platform", value: "Phone Only") LabeledContent("Platform", value: "Phone Only")
} }
} }
.navigationTitle("Keychain")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {

View File

@ -13,12 +13,13 @@ struct MigrationDemo: View {
Section("The Scenario") { 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.") 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(Color.Text.secondary)
} }
Section("Step 1: Setup Legacy Data") { Section("Step 1: Setup Legacy Data") {
Text("First, save a value to the 'legacy' key in UserDefaults.") Text("First, save a value to the 'legacy' key in UserDefaults.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary)
TextField("Legacy Value", text: $legacyValue) TextField("Legacy Value", text: $legacyValue)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
@ -30,8 +31,9 @@ struct MigrationDemo: View {
} }
Section("Step 2: Trigger Migration") { 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.") 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 using the new migration protocol.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: loadFromModern) { Button(action: loadFromModern) {
Label("Load from Modern (Keychain)", systemImage: "sparkles") Label("Load from Modern (Keychain)", systemImage: "sparkles")
@ -40,17 +42,18 @@ struct MigrationDemo: View {
if !modernValue.isEmpty { if !modernValue.isEmpty {
LabeledContent("Migrated Value", value: modernValue) LabeledContent("Migrated Value", value: modernValue)
.foregroundStyle(.green) .foregroundStyle(Color.Status.success)
.bold() .bold()
} }
} }
Section("Step 3: Proactive Sweep (Drain)") { Section("Step 3: Proactive Sweep (Drain)") {
Text("Even if the modern key already has data, you can force a 'Sweep' of legacy sources. Try saving a NEW value to Legacy, then click Drain.") Text("Even if the modern key already has data, you can force a sweep of legacy sources. Try saving a new value to Legacy, then click Force Migration.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: runManualMigration) { Button(action: runManualMigration) {
Label("Drain Migration Sources", systemImage: "arrow.up.circle.badge.clock") Label("Force Migration", systemImage: "arrow.up.circle.badge.clock")
} }
.disabled(isLoading) .disabled(isLoading)
} }
@ -58,6 +61,7 @@ struct MigrationDemo: View {
Section("Step 4: Verify Cleanup") { Section("Step 4: Verify Cleanup") {
Text("Check if the data was actually removed from UserDefaults after migration.") Text("Check if the data was actually removed from UserDefaults after migration.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: checkLegacyExists) { Button(action: checkLegacyExists) {
Label("Check Legacy Exists?", systemImage: "magnifyingglass") Label("Check Legacy Exists?", systemImage: "magnifyingglass")
@ -69,7 +73,7 @@ struct MigrationDemo: View {
Section { Section {
Text(statusMessage) Text(statusMessage)
.font(.caption) .font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? .red : .blue) .foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
} }
} }
} }
@ -113,14 +117,14 @@ struct MigrationDemo: View {
private func runManualMigration() { private func runManualMigration() {
isLoading = true isLoading = true
statusMessage = "Draining migration sources..." statusMessage = "Running manual migration..."
Task { Task {
do { do {
let key = StorageKeys.ModernMigrationDestinationKey() let key = StorageKeys.ModernMigrationDestinationKey()
try await StorageRouter.shared.migrate(for: key) _ = try await StorageRouter.shared.forceMigration(for: key)
// Refresh modern value display // Refresh modern value display
modernValue = try await StorageRouter.shared.get(key) modernValue = try await StorageRouter.shared.get(key)
statusMessage = "Proactive migration complete. Legacy data drained into Keychain." statusMessage = "Manual migration complete. Legacy data drained into Keychain."
} catch { } catch {
statusMessage = "Error: \(error.localizedDescription)" statusMessage = "Error: \(error.localizedDescription)"
} }

View File

@ -0,0 +1,39 @@
import SwiftUI
struct MigrationHubView: View {
var body: some View {
Form {
Section("Migration Demos") {
NavigationLink("Simple Legacy Migration") {
MigrationDemo()
}
NavigationLink("Transforming Migration") {
TransformingMigrationDemo()
}
NavigationLink("Aggregating Migration") {
AggregatingMigrationDemo()
}
NavigationLink("Conditional Migration") {
ConditionalMigrationDemo()
}
}
Section("Overview") {
Text("Explore migrations that move legacy values into modern storage with transformation, aggregation, and conditional rules.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
}
.navigationTitle("Migrations")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationStack {
MigrationHubView()
}
}

View File

@ -103,7 +103,6 @@ struct PlatformSyncDemo: View {
LabeledContent("Max Auto-Sync Size", value: "100 KB") LabeledContent("Max Auto-Sync Size", value: "100 KB")
} }
} }
.navigationTitle("Platform & Sync")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {

View File

@ -0,0 +1,102 @@
import SwiftUI
import LocalData
@MainActor
struct TransformingMigrationDemo: 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("A legacy profile name stored as a single string is migrated into a structured model with first and last names.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Section("Step 1: Setup Legacy Data") {
Text("Save a full name into the legacy UserDefaults key.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
TextField("Legacy Full Name", text: $legacyValue)
.textFieldStyle(.roundedBorder)
Button(action: saveToLegacy) {
Label("Save Legacy Name", systemImage: "person.crop.circle.badge.plus")
}
.disabled(legacyValue.isEmpty || isLoading)
}
Section("Step 2: Trigger Migration") {
Text("Load the modern key to transform the legacy value into a structured profile.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: loadFromModern) {
Label("Load Modern Profile", systemImage: "sparkles")
}
.disabled(isLoading)
if !modernValue.isEmpty {
LabeledContent("Migrated Name", value: modernValue)
.foregroundStyle(Color.Status.success)
.bold()
}
}
if !statusMessage.isEmpty {
Section {
Text(statusMessage)
.font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
}
}
}
.navigationTitle("Transforming Migration")
.navigationBarTitleDisplayMode(.inline)
}
private func saveToLegacy() {
isLoading = true
Task {
do {
let key = StorageKeys.LegacyProfileNameKey()
try await StorageRouter.shared.set(legacyValue, for: key)
statusMessage = "✓ Saved legacy name \(legacyValue)"
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
private func loadFromModern() {
isLoading = true
statusMessage = "Loading modern profile..."
Task {
do {
let key = StorageKeys.ModernProfileNameKey()
let value = try await StorageRouter.shared.get(key)
modernValue = format(value)
statusMessage = "✓ Migration complete."
} catch StorageError.notFound {
statusMessage = "Modern key is empty (and no legacy data found)."
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}
private func format(_ value: ProfileName) -> String {
let trimmedFirst = value.firstName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLast = value.lastName.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedLast.isEmpty {
return trimmedFirst
}
return "\(trimmedFirst) \(trimmedLast)"
}
}

View File

@ -125,7 +125,6 @@ struct UserDefaultsDemo: View {
LabeledContent("Sync Policy", value: "Automatic Small") LabeledContent("Sync Policy", value: "Automatic Small")
} }
} }
.navigationTitle("UserDefaults")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {

View File

@ -16,8 +16,8 @@ public enum StorageServiceIdentifiers {
} }
public static var appGroupIdentifier: String { public static var appGroupIdentifier: String {
let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ??
"group.\(bundleIdentifier.lowercased())" "group.\(bundleIdentifier)"
return identifier return identifier
} }