Add DemoDestination, AppStorageCatalog, StorageKeys, LegacyNotificationSettingKey (+14 more)

This commit is contained in:
Matt Bruce 2026-01-16 13:48:06 -06:00
parent c50c9cb60e
commit 0db85ddc49
15 changed files with 584 additions and 47 deletions

View File

@ -10,47 +10,47 @@ import LocalData
struct ContentView: View {
var body: some View {
TabView {
NavigationStack {
UserDefaultsDemo()
NavigationStack {
List {
Section("Storage Demos") {
NavigationLink(value: DemoDestination.userDefaults) {
Label("UserDefaults", systemImage: "gearshape.fill")
}
NavigationLink(value: DemoDestination.keychain) {
Label("Keychain", systemImage: "lock.fill")
}
NavigationLink(value: DemoDestination.files) {
Label("File Storage", systemImage: "doc.fill")
}
NavigationLink(value: DemoDestination.encrypted) {
Label("Encrypted Storage", systemImage: "lock.shield.fill")
}
NavigationLink(value: DemoDestination.sync) {
Label("Watch Sync", systemImage: "arrow.triangle.2.circlepath")
}
NavigationLink(value: DemoDestination.migration) {
Label("Migrations", systemImage: "sparkles")
}
}
}
.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
switch destination {
case .userDefaults:
UserDefaultsDemo()
case .keychain:
KeychainDemo()
case .files:
FileSystemDemo()
case .encrypted:
EncryptedStorageDemo()
case .sync:
PlatformSyncDemo()
case .migration:
MigrationHubView()
}
}
}
}
@ -59,3 +59,12 @@ struct ContentView: View {
#Preview {
ContentView()
}
private enum DemoDestination: Hashable {
case userDefaults
case keychain
case files
case encrypted
case sync
case migration
}

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

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

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

@ -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") {

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

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