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_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
|
||||||
|
|||||||
10
README.md
10
README.md
@ -42,7 +42,7 @@ SharedPackage/
|
|||||||
└── Models/
|
└── Models/
|
||||||
└── UserProfile.swift
|
└── UserProfile.swift
|
||||||
SecureStorageSample/
|
SecureStorageSample/
|
||||||
├── ContentView.swift # Tabbed navigation
|
├── ContentView.swift # List-based navigation
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── Credential.swift
|
│ ├── Credential.swift
|
||||||
│ └── SampleLocationData.swift
|
│ └── SampleLocationData.swift
|
||||||
@ -112,6 +112,7 @@ The app demonstrates various storage configurations:
|
|||||||
### Encrypted Storage
|
### Encrypted Storage
|
||||||
- AES-256-GCM or ChaCha20-Poly1305 encryption
|
- AES-256-GCM or ChaCha20-Poly1305 encryption
|
||||||
- PBKDF2 or HKDF key derivation
|
- PBKDF2 or HKDF key derivation
|
||||||
|
- PBKDF2 iteration count must remain consistent or existing data will not decrypt
|
||||||
- Complete file protection
|
- Complete file protection
|
||||||
- External key material example via `KeyMaterialProviding`
|
- External key material example via `KeyMaterialProviding`
|
||||||
- Global encryption configuration (Keychain service/account) in app `init`
|
- Global encryption configuration (Keychain service/account) in app `init`
|
||||||
@ -122,8 +123,11 @@ The app demonstrates various storage configurations:
|
|||||||
- Global sync configuration (max file size) in app `init`
|
- Global sync configuration (max file size) in app `init`
|
||||||
|
|
||||||
### Data Migration
|
### Data Migration
|
||||||
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access.
|
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access using protocol-based migration.
|
||||||
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.migrate(for:)`.
|
- **Transforming**: Converts a legacy full-name string into a structured `ProfileName`.
|
||||||
|
- **Aggregating**: Combines legacy notification + theme settings into `UnifiedSettings`.
|
||||||
|
- **Conditional**: Migrates app mode only when the version rule is met.
|
||||||
|
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.forceMigration(for:)`.
|
||||||
- **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`.
|
- **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`.
|
||||||
|
|
||||||
## Global Configuration
|
## Global Configuration
|
||||||
|
|||||||
@ -8,49 +8,85 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import LocalData
|
import LocalData
|
||||||
|
|
||||||
|
enum DemoDestination: Hashable, CaseIterable {
|
||||||
|
case userDefaults
|
||||||
|
case keychain
|
||||||
|
case files
|
||||||
|
case encrypted
|
||||||
|
case sync
|
||||||
|
case migration
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
switch self {
|
||||||
|
case .userDefaults:
|
||||||
|
return AnyView(UserDefaultsDemo())
|
||||||
|
case .keychain:
|
||||||
|
return AnyView(KeychainDemo())
|
||||||
|
case .files:
|
||||||
|
return AnyView(FileSystemDemo())
|
||||||
|
case .encrypted:
|
||||||
|
return AnyView(EncryptedStorageDemo())
|
||||||
|
case .sync:
|
||||||
|
return AnyView(PlatformSyncDemo())
|
||||||
|
case .migration:
|
||||||
|
return AnyView(MigrationHubView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .userDefaults:
|
||||||
|
return "UserDefaults"
|
||||||
|
case .keychain:
|
||||||
|
return "Keychain"
|
||||||
|
case .files:
|
||||||
|
return "File Storage"
|
||||||
|
case .encrypted:
|
||||||
|
return "Encrypted Storage"
|
||||||
|
case .sync:
|
||||||
|
return "Platform & Sync"
|
||||||
|
case .migration:
|
||||||
|
return "Migrations"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .userDefaults:
|
||||||
|
return "gearshape.fill"
|
||||||
|
case .keychain:
|
||||||
|
return "lock.fill"
|
||||||
|
case .files:
|
||||||
|
return "doc.fill"
|
||||||
|
case .encrypted:
|
||||||
|
return "lock.shield.fill"
|
||||||
|
case .sync:
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
case .migration:
|
||||||
|
return "sparkles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
NavigationStack {
|
||||||
NavigationStack {
|
List {
|
||||||
UserDefaultsDemo()
|
Section("Storage Demos") {
|
||||||
|
ForEach(DemoDestination.allCases, id: \.self) { demo in
|
||||||
|
NavigationLink(value: demo) {
|
||||||
|
Label(demo.title, systemImage: demo.systemImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.scrollIndicators(.hidden)
|
||||||
Label("Defaults", systemImage: "gearshape.fill")
|
.navigationTitle("Secure Storage")
|
||||||
}
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationDestination(for: DemoDestination.self) { destination in
|
||||||
NavigationStack {
|
return destination
|
||||||
KeychainDemo()
|
.view
|
||||||
}
|
.navigationTitle(destination.title)
|
||||||
.tabItem {
|
|
||||||
Label("Keychain", systemImage: "lock.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
FileSystemDemo()
|
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label("Files", systemImage: "doc.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
EncryptedStorageDemo()
|
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label("Encrypted", systemImage: "lock.shield.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
PlatformSyncDemo()
|
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label("Sync", systemImage: "arrow.triangle.2.circlepath")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
MigrationDemo()
|
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label("Migration", systemImage: "sparkles")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
await StorageRouter.shared.updateStorageConfiguration(storageConfig)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
|
try await StorageRouter.shared.registerCatalog(AppStorageCatalog(), migrateImmediately: true)
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure("Storage catalog registration failed: \(error)")
|
assertionFailure("Storage catalog registration failed: \(error)")
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ struct SecureStorageSampleApp: App {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
|
let report = StorageAuditReport.renderText(AppStorageCatalog())
|
||||||
print(report)
|
print(report)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import LocalData
|
|||||||
import SharedKit
|
import SharedKit
|
||||||
|
|
||||||
|
|
||||||
nonisolated struct AppStorageCatalog: StorageKeyCatalog {
|
struct AppStorageCatalog: StorageKeyCatalog {
|
||||||
static var allKeys: [AnyStorageKey] {
|
var allKeys: [AnyStorageKey] {
|
||||||
[
|
[
|
||||||
.key(StorageKeys.AppVersionKey()),
|
.key(StorageKeys.AppVersionKey()),
|
||||||
.key(StorageKeys.UserPreferencesKey()),
|
.key(StorageKeys.UserPreferencesKey()),
|
||||||
@ -23,7 +23,14 @@ nonisolated struct AppStorageCatalog: StorageKeyCatalog {
|
|||||||
.key(StorageKeys.AppGroupUserDefaultsKey()),
|
.key(StorageKeys.AppGroupUserDefaultsKey()),
|
||||||
.key(StorageKeys.AppGroupUserProfileKey()),
|
.key(StorageKeys.AppGroupUserProfileKey()),
|
||||||
.key(StorageKeys.LegacyMigrationSourceKey()),
|
.key(StorageKeys.LegacyMigrationSourceKey()),
|
||||||
.key(StorageKeys.ModernMigrationDestinationKey())
|
.key(StorageKeys.ModernMigrationDestinationKey()),
|
||||||
|
.key(StorageKeys.LegacyProfileNameKey()),
|
||||||
|
.key(StorageKeys.ModernProfileNameKey()),
|
||||||
|
.key(StorageKeys.LegacyNotificationSettingKey()),
|
||||||
|
.key(StorageKeys.LegacyThemeSettingKey()),
|
||||||
|
.key(StorageKeys.ModernUnifiedSettingsKey()),
|
||||||
|
.key(StorageKeys.LegacyAppModeKey()),
|
||||||
|
.key(StorageKeys.ModernAppModeKey())
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ extension StorageKeys {
|
|||||||
let serializer: Serializer<String> = .json
|
let serializer: Serializer<String> = .json
|
||||||
let owner = "SampleApp"
|
let owner = "SampleApp"
|
||||||
let description = "Stores a shared setting readable by app extensions."
|
let description = "Stores a shared setting readable by app extensions."
|
||||||
let availability: PlatformAvailability = .all
|
let availability: PlatformAvailability = .phoneOnly
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ extension StorageKeys {
|
|||||||
let serializer: Serializer<UserProfile> = .json
|
let serializer: Serializer<UserProfile> = .json
|
||||||
let owner = "SampleApp"
|
let owner = "SampleApp"
|
||||||
let description = "Stores a profile shared between the app and extensions."
|
let description = "Stores a profile shared between the app and extensions."
|
||||||
let availability: PlatformAvailability = .all
|
let availability: PlatformAvailability = .phoneOnly
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
init(directory: FileDirectory = .documents) {
|
init(directory: FileDirectory = .documents) {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ extension StorageKeys {
|
|||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
init(iterations: Int = 10_000) {
|
init(iterations: Int = 10_000) {
|
||||||
|
// NOTE: PBKDF2 iterations must remain stable for existing data; changing this breaks decryption.
|
||||||
self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations)))
|
self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 description = "Modern key in Keychain with biometric security."
|
||||||
let availability: PlatformAvailability = .all
|
let availability: PlatformAvailability = .all
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
// Define the migration path
|
var migration: AnyStorageMigration? {
|
||||||
var migrationSources: [AnyStorageKey] {
|
AnyStorageMigration(
|
||||||
[AnyStorageKey(LegacyMigrationSourceKey())]
|
SimpleLegacyMigration(
|
||||||
|
destinationKey: self,
|
||||||
|
sourceKey: .key(LegacyMigrationSourceKey())
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
||||||
|
IterationWarningView()
|
||||||
|
.padding(.top, Design.Spacing.xSmall)
|
||||||
|
|
||||||
Text("Higher iterations = more secure but slower")
|
Text("Higher iterations = more secure but slower")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -113,7 +116,6 @@ struct EncryptedStorageDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone Only")
|
LabeledContent("Platform", value: "Phone Only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Encrypted Storage")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
@ -211,3 +213,11 @@ struct EncryptedStorageDemo: View {
|
|||||||
EncryptedStorageDemo()
|
EncryptedStorageDemo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct IterationWarningView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.Status.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -83,6 +83,7 @@ struct FileSystemDemo: View {
|
|||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
Section("App Group Storage") {
|
Section("App Group Storage") {
|
||||||
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
|
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@ -113,6 +114,7 @@ struct FileSystemDemo: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if let profile = storedProfile {
|
if let profile = storedProfile {
|
||||||
Section("Retrieved Profile") {
|
Section("Retrieved Profile") {
|
||||||
@ -155,7 +157,6 @@ struct FileSystemDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone + Watch Sync")
|
LabeledContent("Platform", value: "Phone + Watch Sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("File System")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -102,7 +102,6 @@ struct KeychainDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone Only")
|
LabeledContent("Platform", value: "Phone Only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Keychain")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -13,12 +13,13 @@ struct MigrationDemo: View {
|
|||||||
Section("The Scenario") {
|
Section("The Scenario") {
|
||||||
Text("Imagine you have an old version of the app that stored a User ID in plain UserDefaults. Now, you want to move it to the secure Keychain automatically without the user noticing.")
|
Text("Imagine you have an old version of the app that stored a User ID in plain UserDefaults. Now, you want to move it to the secure Keychain automatically without the user noticing.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 1: Setup Legacy Data") {
|
Section("Step 1: Setup Legacy Data") {
|
||||||
Text("First, save a value to the 'legacy' key in UserDefaults.")
|
Text("First, save a value to the 'legacy' key in UserDefaults.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
TextField("Legacy Value", text: $legacyValue)
|
TextField("Legacy Value", text: $legacyValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
@ -30,8 +31,9 @@ struct MigrationDemo: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 2: Trigger Migration") {
|
Section("Step 2: Trigger Migration") {
|
||||||
Text("Now, attempt to load from the 'modern' Keychain key. It will automatically check the legacy key, move the data, and delete the old record.")
|
Text("Now, attempt to load from the 'modern' Keychain key. It will automatically check the legacy key, move the data, and delete the old record using the new migration protocol.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
Button(action: loadFromModern) {
|
Button(action: loadFromModern) {
|
||||||
Label("Load from Modern (Keychain)", systemImage: "sparkles")
|
Label("Load from Modern (Keychain)", systemImage: "sparkles")
|
||||||
@ -40,17 +42,18 @@ struct MigrationDemo: View {
|
|||||||
|
|
||||||
if !modernValue.isEmpty {
|
if !modernValue.isEmpty {
|
||||||
LabeledContent("Migrated Value", value: modernValue)
|
LabeledContent("Migrated Value", value: modernValue)
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(Color.Status.success)
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 3: Proactive Sweep (Drain)") {
|
Section("Step 3: Proactive Sweep (Drain)") {
|
||||||
Text("Even if the modern key already has data, you can force a 'Sweep' of legacy sources. Try saving a NEW value to Legacy, then click Drain.")
|
Text("Even if the modern key already has data, you can force a sweep of legacy sources. Try saving a new value to Legacy, then click Force Migration.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
Button(action: runManualMigration) {
|
Button(action: runManualMigration) {
|
||||||
Label("Drain Migration Sources", systemImage: "arrow.up.circle.badge.clock")
|
Label("Force Migration", systemImage: "arrow.up.circle.badge.clock")
|
||||||
}
|
}
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
@ -58,6 +61,7 @@ struct MigrationDemo: View {
|
|||||||
Section("Step 4: Verify Cleanup") {
|
Section("Step 4: Verify Cleanup") {
|
||||||
Text("Check if the data was actually removed from UserDefaults after migration.")
|
Text("Check if the data was actually removed from UserDefaults after migration.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
Button(action: checkLegacyExists) {
|
Button(action: checkLegacyExists) {
|
||||||
Label("Check Legacy Exists?", systemImage: "magnifyingglass")
|
Label("Check Legacy Exists?", systemImage: "magnifyingglass")
|
||||||
@ -69,7 +73,7 @@ struct MigrationDemo: View {
|
|||||||
Section {
|
Section {
|
||||||
Text(statusMessage)
|
Text(statusMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? .red : .blue)
|
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,14 +117,14 @@ struct MigrationDemo: View {
|
|||||||
|
|
||||||
private func runManualMigration() {
|
private func runManualMigration() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
statusMessage = "Draining migration sources..."
|
statusMessage = "Running manual migration..."
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let key = StorageKeys.ModernMigrationDestinationKey()
|
let key = StorageKeys.ModernMigrationDestinationKey()
|
||||||
try await StorageRouter.shared.migrate(for: key)
|
_ = try await StorageRouter.shared.forceMigration(for: key)
|
||||||
// Refresh modern value display
|
// Refresh modern value display
|
||||||
modernValue = try await StorageRouter.shared.get(key)
|
modernValue = try await StorageRouter.shared.get(key)
|
||||||
statusMessage = "✓ Proactive migration complete. Legacy data drained into Keychain."
|
statusMessage = "✓ Manual migration complete. Legacy data drained into Keychain."
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
statusMessage = "Error: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
LabeledContent("Max Auto-Sync Size", value: "100 KB")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Platform & Sync")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
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")
|
LabeledContent("Sync Policy", value: "Automatic Small")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("UserDefaults")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -16,8 +16,8 @@ public enum StorageServiceIdentifiers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static var appGroupIdentifier: String {
|
public static var appGroupIdentifier: String {
|
||||||
let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ??
|
let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ??
|
||||||
"group.\(bundleIdentifier.lowercased())"
|
"group.\(bundleIdentifier)"
|
||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user