Compare commits

..

No commits in common. "04b44dcaadee05bcc2fde7a12883253d8209ee31" and "c5a79e3f8caa50b2ae96ad8dff2d3d79e9580e13" have entirely different histories.

26 changed files with 70 additions and 677 deletions

View File

@ -6,6 +6,5 @@ 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 # List-based navigation ├── ContentView.swift # Tabbed navigation
├── Models/ ├── Models/
│ ├── Credential.swift │ ├── Credential.swift
│ └── SampleLocationData.swift │ └── SampleLocationData.swift
@ -112,7 +112,6 @@ 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`
@ -123,11 +122,8 @@ 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 using protocol-based migration. - **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access.
- **Transforming**: Converts a legacy full-name string into a structured `ProfileName`. - **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.migrate(for:)`.
- **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,85 +8,49 @@
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 {
NavigationStack { TabView {
List { NavigationStack {
Section("Storage Demos") { UserDefaultsDemo()
ForEach(DemoDestination.allCases, id: \.self) { demo in
NavigationLink(value: demo) {
Label(demo.title, systemImage: demo.systemImage)
}
}
}
} }
.scrollIndicators(.hidden) .tabItem {
.navigationTitle("Secure Storage") Label("Defaults", systemImage: "gearshape.fill")
.navigationBarTitleDisplayMode(.inline) }
.navigationDestination(for: DemoDestination.self) { destination in
return destination NavigationStack {
.view KeychainDemo()
.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

@ -1,23 +0,0 @@
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

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

View File

@ -1,6 +0,0 @@
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(), migrateImmediately: true) try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, 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(AppStorageCatalog()) let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
print(report) print(report)
#endif #endif
} }

View File

@ -3,8 +3,8 @@ import LocalData
import SharedKit import SharedKit
struct AppStorageCatalog: StorageKeyCatalog { nonisolated struct AppStorageCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] { static var allKeys: [AnyStorageKey] {
[ [
.key(StorageKeys.AppVersionKey()), .key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey()), .key(StorageKeys.UserPreferencesKey()),
@ -23,14 +23,7 @@ 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 = .phoneOnly let availability: PlatformAvailability = .all
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 = .phoneOnly let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
init(directory: FileDirectory = .documents) { init(directory: FileDirectory = .documents) {

View File

@ -17,7 +17,6 @@ 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

@ -1,73 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -32,13 +32,9 @@ extension StorageKeys {
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
var migration: AnyStorageMigration? { // Define the migration path
AnyStorageMigration( var migrationSources: [AnyStorageKey] {
SimpleLegacyMigration( [AnyStorageKey(LegacyMigrationSourceKey())]
destinationKey: self,
sourceKey: .key(LegacyMigrationSourceKey())
)
)
} }
} }
} }

View File

@ -1,48 +0,0 @@
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

@ -1,103 +0,0 @@
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

@ -1,93 +0,0 @@
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,9 +37,6 @@ 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)
@ -116,6 +113,7 @@ 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) {
@ -213,11 +211,3 @@ 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,7 +83,6 @@ 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)
@ -114,7 +113,6 @@ 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") {
@ -157,6 +155,7 @@ 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,6 +102,7 @@ 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,13 +13,12 @@ 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(Color.Text.secondary) .foregroundStyle(.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)
@ -31,9 +30,8 @@ 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 using the new migration protocol.") 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) .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")
@ -42,18 +40,17 @@ struct MigrationDemo: View {
if !modernValue.isEmpty { if !modernValue.isEmpty {
LabeledContent("Migrated Value", value: modernValue) LabeledContent("Migrated Value", value: modernValue)
.foregroundStyle(Color.Status.success) .foregroundStyle(.green)
.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 Force Migration.") 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.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary)
Button(action: runManualMigration) { Button(action: runManualMigration) {
Label("Force Migration", systemImage: "arrow.up.circle.badge.clock") Label("Drain Migration Sources", systemImage: "arrow.up.circle.badge.clock")
} }
.disabled(isLoading) .disabled(isLoading)
} }
@ -61,7 +58,6 @@ 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")
@ -73,7 +69,7 @@ struct MigrationDemo: View {
Section { Section {
Text(statusMessage) Text(statusMessage)
.font(.caption) .font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info) .foregroundStyle(statusMessage.contains("Error") ? .red : .blue)
} }
} }
} }
@ -117,14 +113,14 @@ struct MigrationDemo: View {
private func runManualMigration() { private func runManualMigration() {
isLoading = true isLoading = true
statusMessage = "Running manual migration..." statusMessage = "Draining migration sources..."
Task { Task {
do { do {
let key = StorageKeys.ModernMigrationDestinationKey() let key = StorageKeys.ModernMigrationDestinationKey()
_ = try await StorageRouter.shared.forceMigration(for: key) try await StorageRouter.shared.migrate(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 = "Manual migration complete. Legacy data drained into Keychain." statusMessage = "Proactive migration complete. Legacy data drained into Keychain."
} catch { } catch {
statusMessage = "Error: \(error.localizedDescription)" statusMessage = "Error: \(error.localizedDescription)"
} }

View File

@ -1,39 +0,0 @@
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,6 +103,7 @@ 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

@ -1,102 +0,0 @@
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,6 +125,7 @@ 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

@ -17,7 +17,7 @@ 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)" "group.\(bundleIdentifier.lowercased())"
return identifier return identifier
} }