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_TeamID = $(TEAM_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

View File

@ -42,7 +42,7 @@ SharedPackage/
└── Models/
└── UserProfile.swift
SecureStorageSample/
├── ContentView.swift # Tabbed navigation
├── ContentView.swift # List-based navigation
├── Models/
│ ├── Credential.swift
│ └── SampleLocationData.swift
@ -112,6 +112,7 @@ The app demonstrates various storage configurations:
### Encrypted Storage
- AES-256-GCM or ChaCha20-Poly1305 encryption
- PBKDF2 or HKDF key derivation
- PBKDF2 iteration count must remain consistent or existing data will not decrypt
- Complete file protection
- External key material example via `KeyMaterialProviding`
- 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`
### Data Migration
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access.
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.migrate(for:)`.
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access using protocol-based migration.
- **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)`.
## Global Configuration

View File

@ -8,49 +8,85 @@
import SwiftUI
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 {
var body: some View {
TabView {
NavigationStack {
UserDefaultsDemo()
NavigationStack {
List {
Section("Storage Demos") {
ForEach(DemoDestination.allCases, id: \.self) { demo in
NavigationLink(value: demo) {
Label(demo.title, systemImage: demo.systemImage)
}
}
}
}
.tabItem {
Label("Defaults", systemImage: "gearshape.fill")
}
NavigationStack {
KeychainDemo()
}
.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")
.scrollIndicators(.hidden)
.navigationTitle("Secure Storage")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: DemoDestination.self) { destination in
return destination
.view
.navigationTitle(destination.title)
}
}
}

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

View File

@ -3,8 +3,8 @@ import LocalData
import SharedKit
nonisolated struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
struct AppStorageCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
[
.key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey()),
@ -23,7 +23,14 @@ nonisolated struct AppStorageCatalog: StorageKeyCatalog {
.key(StorageKeys.AppGroupUserDefaultsKey()),
.key(StorageKeys.AppGroupUserProfileKey()),
.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 owner = "SampleApp"
let description = "Stores a shared setting readable by app extensions."
let availability: PlatformAvailability = .all
let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never
}
}

View File

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

View File

@ -17,6 +17,7 @@ extension StorageKeys {
let syncPolicy: SyncPolicy = .never
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)))
}
}

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 availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
// Define the migration path
var migrationSources: [AnyStorageKey] {
[AnyStorageKey(LegacyMigrationSourceKey())]
var migration: AnyStorageMigration? {
AnyStorageMigration(
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)
.font(.caption)
IterationWarningView()
.padding(.top, Design.Spacing.xSmall)
Text("Higher iterations = more secure but slower")
.font(.caption2)
.foregroundStyle(.secondary)
@ -113,7 +116,6 @@ struct EncryptedStorageDemo: View {
LabeledContent("Platform", value: "Phone Only")
}
}
.navigationTitle("Encrypted Storage")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
@ -211,3 +213,11 @@ struct EncryptedStorageDemo: View {
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)
}
#if os(iOS)
Section("App Group Storage") {
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
.font(.caption)
@ -113,6 +114,7 @@ struct FileSystemDemo: View {
.foregroundStyle(.red)
.disabled(isLoading)
}
#endif
if let profile = storedProfile {
Section("Retrieved Profile") {
@ -155,7 +157,6 @@ struct FileSystemDemo: View {
LabeledContent("Platform", value: "Phone + Watch Sync")
}
}
.navigationTitle("File System")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {

View File

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

View File

@ -13,12 +13,13 @@ struct MigrationDemo: View {
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)
.foregroundStyle(Color.Text.secondary)
}
Section("Step 1: Setup Legacy Data") {
Text("First, save a value to the 'legacy' key in UserDefaults.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
TextField("Legacy Value", text: $legacyValue)
.textFieldStyle(.roundedBorder)
@ -30,8 +31,9 @@ struct MigrationDemo: View {
}
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)
.foregroundStyle(Color.Text.secondary)
Button(action: loadFromModern) {
Label("Load from Modern (Keychain)", systemImage: "sparkles")
@ -40,17 +42,18 @@ struct MigrationDemo: View {
if !modernValue.isEmpty {
LabeledContent("Migrated Value", value: modernValue)
.foregroundStyle(.green)
.foregroundStyle(Color.Status.success)
.bold()
}
}
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)
.foregroundStyle(Color.Text.secondary)
Button(action: runManualMigration) {
Label("Drain Migration Sources", systemImage: "arrow.up.circle.badge.clock")
Label("Force Migration", systemImage: "arrow.up.circle.badge.clock")
}
.disabled(isLoading)
}
@ -58,6 +61,7 @@ struct MigrationDemo: View {
Section("Step 4: Verify Cleanup") {
Text("Check if the data was actually removed from UserDefaults after migration.")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: checkLegacyExists) {
Label("Check Legacy Exists?", systemImage: "magnifyingglass")
@ -69,7 +73,7 @@ struct MigrationDemo: View {
Section {
Text(statusMessage)
.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() {
isLoading = true
statusMessage = "Draining migration sources..."
statusMessage = "Running manual migration..."
Task {
do {
let key = StorageKeys.ModernMigrationDestinationKey()
try await StorageRouter.shared.migrate(for: key)
_ = try await StorageRouter.shared.forceMigration(for: key)
// Refresh modern value display
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 {
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")
}
}
.navigationTitle("Platform & Sync")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
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")
}
}
.navigationTitle("UserDefaults")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {

View File

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