Compare commits
6 Commits
c5a79e3f8c
...
04b44dcaad
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b44dcaad | |||
| 0a28f55229 | |||
| d2bf6004e1 | |||
| e13027356d | |||
| 7219c69b26 | |||
| 8d591883d5 |
@ -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
|
||||
|
||||
10
README.md
10
README.md
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
SecureStorageSample/Design/DesignConstants.swift
Normal file
23
SecureStorageSample/Design/DesignConstants.swift
Normal 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
|
||||
}
|
||||
}
|
||||
6
SecureStorageSample/Models/ProfileName.swift
Normal file
6
SecureStorageSample/Models/ProfileName.swift
Normal file
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated struct ProfileName: Codable, Sendable {
|
||||
let firstName: String
|
||||
let lastName: String
|
||||
}
|
||||
6
SecureStorageSample/Models/UnifiedSettings.swift
Normal file
6
SecureStorageSample/Models/UnifiedSettings.swift
Normal file
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated struct UnifiedSettings: Codable, Sendable {
|
||||
let notificationsEnabled: Bool
|
||||
let theme: String
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
103
SecureStorageSample/Views/AggregatingMigrationDemo.swift
Normal file
103
SecureStorageSample/Views/AggregatingMigrationDemo.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
93
SecureStorageSample/Views/ConditionalMigrationDemo.swift
Normal file
93
SecureStorageSample/Views/ConditionalMigrationDemo.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -102,7 +102,6 @@ struct KeychainDemo: View {
|
||||
LabeledContent("Platform", value: "Phone Only")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Keychain")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
|
||||
39
SecureStorageSample/Views/MigrationHubView.swift
Normal file
39
SecureStorageSample/Views/MigrationHubView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -103,7 +103,6 @@ struct PlatformSyncDemo: View {
|
||||
LabeledContent("Max Auto-Sync Size", value: "100 KB")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Platform & Sync")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
|
||||
102
SecureStorageSample/Views/TransformingMigrationDemo.swift
Normal file
102
SecureStorageSample/Views/TransformingMigrationDemo.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -125,7 +125,6 @@ struct UserDefaultsDemo: View {
|
||||
LabeledContent("Sync Policy", value: "Automatic Small")
|
||||
}
|
||||
}
|
||||
.navigationTitle("UserDefaults")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user