Compare commits
No commits in common. "04b44dcaadee05bcc2fde7a12883253d8209ee31" and "c5a79e3f8caa50b2ae96ad8dff2d3d79e9580e13" have entirely different histories.
04b44dcaad
...
c5a79e3f8c
@ -6,6 +6,5 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES
|
|||||||
INFOPLIST_KEY_BaseBundleID = $(BASE_BUNDLE_ID)
|
INFOPLIST_KEY_BaseBundleID = $(BASE_BUNDLE_ID)
|
||||||
INFOPLIST_KEY_TeamID = $(TEAM_ID)
|
INFOPLIST_KEY_TeamID = $(TEAM_ID)
|
||||||
INFOPLIST_KEY_AppGroupID = $(APP_GROUP_ID)
|
INFOPLIST_KEY_AppGroupID = $(APP_GROUP_ID)
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = SecureStorage uses Face ID to unlock protected Keychain items.
|
|
||||||
|
|
||||||
CODE_SIGN_ENTITLEMENTS = SecureStorageSample/SecureStorageSample.entitlements
|
CODE_SIGN_ENTITLEMENTS = SecureStorageSample/SecureStorageSample.entitlements
|
||||||
|
|||||||
10
README.md
10
README.md
@ -42,7 +42,7 @@ SharedPackage/
|
|||||||
└── Models/
|
└── Models/
|
||||||
└── UserProfile.swift
|
└── UserProfile.swift
|
||||||
SecureStorageSample/
|
SecureStorageSample/
|
||||||
├── ContentView.swift # List-based navigation
|
├── ContentView.swift # Tabbed navigation
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── Credential.swift
|
│ ├── Credential.swift
|
||||||
│ └── SampleLocationData.swift
|
│ └── SampleLocationData.swift
|
||||||
@ -112,7 +112,6 @@ The app demonstrates various storage configurations:
|
|||||||
### Encrypted Storage
|
### Encrypted Storage
|
||||||
- AES-256-GCM or ChaCha20-Poly1305 encryption
|
- AES-256-GCM or ChaCha20-Poly1305 encryption
|
||||||
- PBKDF2 or HKDF key derivation
|
- PBKDF2 or HKDF key derivation
|
||||||
- PBKDF2 iteration count must remain consistent or existing data will not decrypt
|
|
||||||
- Complete file protection
|
- Complete file protection
|
||||||
- External key material example via `KeyMaterialProviding`
|
- External key material example via `KeyMaterialProviding`
|
||||||
- Global encryption configuration (Keychain service/account) in app `init`
|
- Global encryption configuration (Keychain service/account) in app `init`
|
||||||
@ -123,11 +122,8 @@ The app demonstrates various storage configurations:
|
|||||||
- Global sync configuration (max file size) in app `init`
|
- Global sync configuration (max file size) in app `init`
|
||||||
|
|
||||||
### Data Migration
|
### Data Migration
|
||||||
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access using protocol-based migration.
|
- **Fallback**: Automatically moves data from `LegacyMigrationSourceKey` to `ModernMigrationDestinationKey` on first access.
|
||||||
- **Transforming**: Converts a legacy full-name string into a structured `ProfileName`.
|
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.migrate(for:)`.
|
||||||
- **Aggregating**: Combines legacy notification + theme settings into `UnifiedSettings`.
|
|
||||||
- **Conditional**: Migrates app mode only when the version rule is met.
|
|
||||||
- **Manual Sweep**: Explicitly triggers a "drain" of legacy keys to the Keychain using `StorageRouter.shared.forceMigration(for:)`.
|
|
||||||
- **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`.
|
- **Startup Sweep**: Automatically cleanses all registered legacy keys at app launch via `registerCatalog(..., migrateImmediately: true)`.
|
||||||
|
|
||||||
## Global Configuration
|
## Global Configuration
|
||||||
|
|||||||
@ -8,85 +8,49 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import LocalData
|
import LocalData
|
||||||
|
|
||||||
enum DemoDestination: Hashable, CaseIterable {
|
|
||||||
case userDefaults
|
|
||||||
case keychain
|
|
||||||
case files
|
|
||||||
case encrypted
|
|
||||||
case sync
|
|
||||||
case migration
|
|
||||||
|
|
||||||
var view: some View {
|
|
||||||
switch self {
|
|
||||||
case .userDefaults:
|
|
||||||
return AnyView(UserDefaultsDemo())
|
|
||||||
case .keychain:
|
|
||||||
return AnyView(KeychainDemo())
|
|
||||||
case .files:
|
|
||||||
return AnyView(FileSystemDemo())
|
|
||||||
case .encrypted:
|
|
||||||
return AnyView(EncryptedStorageDemo())
|
|
||||||
case .sync:
|
|
||||||
return AnyView(PlatformSyncDemo())
|
|
||||||
case .migration:
|
|
||||||
return AnyView(MigrationHubView())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .userDefaults:
|
|
||||||
return "UserDefaults"
|
|
||||||
case .keychain:
|
|
||||||
return "Keychain"
|
|
||||||
case .files:
|
|
||||||
return "File Storage"
|
|
||||||
case .encrypted:
|
|
||||||
return "Encrypted Storage"
|
|
||||||
case .sync:
|
|
||||||
return "Platform & Sync"
|
|
||||||
case .migration:
|
|
||||||
return "Migrations"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemImage: String {
|
|
||||||
switch self {
|
|
||||||
case .userDefaults:
|
|
||||||
return "gearshape.fill"
|
|
||||||
case .keychain:
|
|
||||||
return "lock.fill"
|
|
||||||
case .files:
|
|
||||||
return "doc.fill"
|
|
||||||
case .encrypted:
|
|
||||||
return "lock.shield.fill"
|
|
||||||
case .sync:
|
|
||||||
return "arrow.triangle.2.circlepath"
|
|
||||||
case .migration:
|
|
||||||
return "sparkles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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 {
|
||||||
|
Label("Defaults", systemImage: "gearshape.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
KeychainDemo()
|
||||||
}
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Keychain", systemImage: "lock.fill")
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
|
||||||
.navigationTitle("Secure Storage")
|
NavigationStack {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
FileSystemDemo()
|
||||||
.navigationDestination(for: DemoDestination.self) { destination in
|
}
|
||||||
return destination
|
.tabItem {
|
||||||
.view
|
Label("Files", systemImage: "doc.fill")
|
||||||
.navigationTitle(destination.title)
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
enum Design {
|
|
||||||
enum Spacing {
|
|
||||||
static let xSmall: CGFloat = 4
|
|
||||||
static let small: CGFloat = 8
|
|
||||||
static let medium: CGFloat = 16
|
|
||||||
static let large: CGFloat = 24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Color {
|
|
||||||
enum Status {
|
|
||||||
static let success = Color.green
|
|
||||||
static let info = Color.blue
|
|
||||||
static let warning = Color.orange
|
|
||||||
static let error = Color.red
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Text {
|
|
||||||
static let secondary = Color.secondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
nonisolated struct ProfileName: Codable, Sendable {
|
|
||||||
let firstName: String
|
|
||||||
let lastName: String
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
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(), migrateImmediately: true)
|
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure("Storage catalog registration failed: \(error)")
|
assertionFailure("Storage catalog registration failed: \(error)")
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ struct SecureStorageSampleApp: App {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let report = StorageAuditReport.renderText(AppStorageCatalog())
|
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
|
||||||
print(report)
|
print(report)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import LocalData
|
|||||||
import SharedKit
|
import SharedKit
|
||||||
|
|
||||||
|
|
||||||
struct AppStorageCatalog: StorageKeyCatalog {
|
nonisolated struct AppStorageCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static var allKeys: [AnyStorageKey] {
|
||||||
[
|
[
|
||||||
.key(StorageKeys.AppVersionKey()),
|
.key(StorageKeys.AppVersionKey()),
|
||||||
.key(StorageKeys.UserPreferencesKey()),
|
.key(StorageKeys.UserPreferencesKey()),
|
||||||
@ -23,14 +23,7 @@ struct AppStorageCatalog: StorageKeyCatalog {
|
|||||||
.key(StorageKeys.AppGroupUserDefaultsKey()),
|
.key(StorageKeys.AppGroupUserDefaultsKey()),
|
||||||
.key(StorageKeys.AppGroupUserProfileKey()),
|
.key(StorageKeys.AppGroupUserProfileKey()),
|
||||||
.key(StorageKeys.LegacyMigrationSourceKey()),
|
.key(StorageKeys.LegacyMigrationSourceKey()),
|
||||||
.key(StorageKeys.ModernMigrationDestinationKey()),
|
.key(StorageKeys.ModernMigrationDestinationKey())
|
||||||
.key(StorageKeys.LegacyProfileNameKey()),
|
|
||||||
.key(StorageKeys.ModernProfileNameKey()),
|
|
||||||
.key(StorageKeys.LegacyNotificationSettingKey()),
|
|
||||||
.key(StorageKeys.LegacyThemeSettingKey()),
|
|
||||||
.key(StorageKeys.ModernUnifiedSettingsKey()),
|
|
||||||
.key(StorageKeys.LegacyAppModeKey()),
|
|
||||||
.key(StorageKeys.ModernAppModeKey())
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ extension StorageKeys {
|
|||||||
let serializer: Serializer<String> = .json
|
let serializer: Serializer<String> = .json
|
||||||
let owner = "SampleApp"
|
let owner = "SampleApp"
|
||||||
let description = "Stores a shared setting readable by app extensions."
|
let description = "Stores a shared setting readable by app extensions."
|
||||||
let availability: PlatformAvailability = .phoneOnly
|
let availability: PlatformAvailability = .all
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ extension StorageKeys {
|
|||||||
let serializer: Serializer<UserProfile> = .json
|
let serializer: Serializer<UserProfile> = .json
|
||||||
let owner = "SampleApp"
|
let owner = "SampleApp"
|
||||||
let description = "Stores a profile shared between the app and extensions."
|
let description = "Stores a profile shared between the app and extensions."
|
||||||
let availability: PlatformAvailability = .phoneOnly
|
let availability: PlatformAvailability = .all
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
init(directory: FileDirectory = .documents) {
|
init(directory: FileDirectory = .documents) {
|
||||||
|
|||||||
@ -17,7 +17,6 @@ extension StorageKeys {
|
|||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
init(iterations: Int = 10_000) {
|
init(iterations: Int = 10_000) {
|
||||||
// NOTE: PBKDF2 iterations must remain stable for existing data; changing this breaks decryption.
|
|
||||||
self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations)))
|
self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
extension StorageKeys {
|
|
||||||
struct LegacyNotificationSettingKey: StorageKey {
|
|
||||||
typealias Value = Bool
|
|
||||||
|
|
||||||
let name = "legacy_notification_setting"
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<Bool> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Legacy notification setting stored as Bool."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LegacyThemeSettingKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
|
|
||||||
let name = "legacy_theme_setting"
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Legacy theme setting stored as a string."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ModernUnifiedSettingsKey: StorageKey {
|
|
||||||
typealias Value = UnifiedSettings
|
|
||||||
|
|
||||||
let name = "modern_unified_settings"
|
|
||||||
let domain: StorageDomain = .fileSystem(directory: .documents)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<UnifiedSettings> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Modern unified settings aggregated from legacy keys."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
|
|
||||||
var migration: AnyStorageMigration? {
|
|
||||||
let sources: [AnyStorageKey] = [
|
|
||||||
.key(StorageKeys.LegacyNotificationSettingKey()),
|
|
||||||
.key(StorageKeys.LegacyThemeSettingKey())
|
|
||||||
]
|
|
||||||
|
|
||||||
return AnyStorageMigration(
|
|
||||||
DefaultAggregatingMigration(
|
|
||||||
destinationKey: self,
|
|
||||||
sourceKeys: sources
|
|
||||||
) { sources in
|
|
||||||
var notificationsEnabled = false
|
|
||||||
var theme = "system"
|
|
||||||
|
|
||||||
for source in sources {
|
|
||||||
if let boolValue = source.value as? Bool {
|
|
||||||
notificationsEnabled = boolValue
|
|
||||||
} else if let stringValue = source.value as? String {
|
|
||||||
theme = stringValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return UnifiedSettings(
|
|
||||||
notificationsEnabled: notificationsEnabled,
|
|
||||||
theme: theme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
extension StorageKeys {
|
|
||||||
struct LegacyAppModeKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
|
|
||||||
let name = "legacy_app_mode"
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Legacy app mode stored in UserDefaults."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ModernAppModeKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
|
|
||||||
let name = "modern_app_mode"
|
|
||||||
let domain: StorageDomain = .keychain(service: "com.mbrucedogs.securestorage")
|
|
||||||
let security: SecurityPolicy = .keychain(
|
|
||||||
accessibility: .afterFirstUnlock,
|
|
||||||
accessControl: .userPresence
|
|
||||||
)
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Modern app mode with conditional migration."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
|
|
||||||
var migration: AnyStorageMigration? {
|
|
||||||
let destination = self
|
|
||||||
let legacy = StorageKeys.LegacyAppModeKey()
|
|
||||||
let fallback = AnyStorageMigration(
|
|
||||||
SimpleLegacyMigration(destinationKey: destination, sourceKey: .key(legacy))
|
|
||||||
)
|
|
||||||
|
|
||||||
return AnyStorageMigration(
|
|
||||||
AppVersionConditionalMigration(
|
|
||||||
destinationKey: destination,
|
|
||||||
minAppVersion: "99.0",
|
|
||||||
fallbackMigration: fallback
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,13 +32,9 @@ extension StorageKeys {
|
|||||||
let availability: PlatformAvailability = .all
|
let availability: PlatformAvailability = .all
|
||||||
let syncPolicy: SyncPolicy = .never
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
|
||||||
var migration: AnyStorageMigration? {
|
// Define the migration path
|
||||||
AnyStorageMigration(
|
var migrationSources: [AnyStorageKey] {
|
||||||
SimpleLegacyMigration(
|
[AnyStorageKey(LegacyMigrationSourceKey())]
|
||||||
destinationKey: self,
|
|
||||||
sourceKey: .key(LegacyMigrationSourceKey())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
extension StorageKeys {
|
|
||||||
struct LegacyProfileNameKey: StorageKey {
|
|
||||||
typealias Value = String
|
|
||||||
|
|
||||||
let name = "legacy_profile_name"
|
|
||||||
let domain: StorageDomain = .userDefaults(suite: nil)
|
|
||||||
let security: SecurityPolicy = .none
|
|
||||||
let serializer: Serializer<String> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Legacy profile name stored as a single string."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ModernProfileNameKey: StorageKey {
|
|
||||||
typealias Value = ProfileName
|
|
||||||
|
|
||||||
let name = "modern_profile_name"
|
|
||||||
let domain: StorageDomain = .keychain(service: "com.mbrucedogs.securestorage")
|
|
||||||
let security: SecurityPolicy = .keychain(
|
|
||||||
accessibility: .afterFirstUnlock,
|
|
||||||
accessControl: .userPresence
|
|
||||||
)
|
|
||||||
let serializer: Serializer<ProfileName> = .json
|
|
||||||
let owner = "MigrationDemo"
|
|
||||||
let description = "Modern profile name stored as structured data."
|
|
||||||
let availability: PlatformAvailability = .all
|
|
||||||
let syncPolicy: SyncPolicy = .never
|
|
||||||
|
|
||||||
var migration: AnyStorageMigration? {
|
|
||||||
let sourceKey = StorageKeys.LegacyProfileNameKey()
|
|
||||||
return AnyStorageMigration(
|
|
||||||
DefaultTransformingMigration(
|
|
||||||
destinationKey: self,
|
|
||||||
sourceKey: sourceKey
|
|
||||||
) { value in
|
|
||||||
let parts = value.split(separator: " ", maxSplits: 1).map(String.init)
|
|
||||||
let firstName = parts.first ?? ""
|
|
||||||
let lastName = parts.count > 1 ? parts[1] : ""
|
|
||||||
return ProfileName(firstName: firstName, lastName: lastName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct AggregatingMigrationDemo: View {
|
|
||||||
@State private var notificationsEnabled = false
|
|
||||||
@State private var theme = ""
|
|
||||||
@State private var modernValue = ""
|
|
||||||
@State private var statusMessage = ""
|
|
||||||
@State private var isLoading = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("The Scenario") {
|
|
||||||
Text("Two legacy settings are aggregated into a single unified settings model during migration.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 1: Setup Legacy Data") {
|
|
||||||
Toggle("Notifications Enabled", isOn: $notificationsEnabled)
|
|
||||||
|
|
||||||
TextField("Theme", text: $theme)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
|
|
||||||
Button(action: saveToLegacy) {
|
|
||||||
Label("Save Legacy Settings", systemImage: "tray.and.arrow.down")
|
|
||||||
}
|
|
||||||
.disabled(theme.isEmpty || isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 2: Trigger Migration") {
|
|
||||||
Text("Load the modern key to aggregate legacy values into unified settings.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: loadFromModern) {
|
|
||||||
Label("Load Unified Settings", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
.disabled(isLoading)
|
|
||||||
|
|
||||||
if !modernValue.isEmpty {
|
|
||||||
LabeledContent("Unified Settings", value: modernValue)
|
|
||||||
.foregroundStyle(Color.Status.success)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !statusMessage.isEmpty {
|
|
||||||
Section {
|
|
||||||
Text(statusMessage)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Aggregating Migration")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveToLegacy() {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await StorageRouter.shared.set(
|
|
||||||
notificationsEnabled,
|
|
||||||
for: StorageKeys.LegacyNotificationSettingKey()
|
|
||||||
)
|
|
||||||
try await StorageRouter.shared.set(
|
|
||||||
theme,
|
|
||||||
for: StorageKeys.LegacyThemeSettingKey()
|
|
||||||
)
|
|
||||||
statusMessage = "✓ Saved legacy settings"
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromModern() {
|
|
||||||
isLoading = true
|
|
||||||
statusMessage = "Loading unified settings..."
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let value = try await StorageRouter.shared.get(StorageKeys.ModernUnifiedSettingsKey())
|
|
||||||
modernValue = format(value)
|
|
||||||
statusMessage = "✓ Migration complete."
|
|
||||||
} catch StorageError.notFound {
|
|
||||||
statusMessage = "Modern key is empty (and no legacy data found)."
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func format(_ value: UnifiedSettings) -> String {
|
|
||||||
let notificationsText = value.notificationsEnabled ? "On" : "Off"
|
|
||||||
let themeText = value.theme.isEmpty ? "system" : value.theme
|
|
||||||
return "Notifications: \(notificationsText), Theme: \(themeText)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ConditionalMigrationDemo: View {
|
|
||||||
@State private var legacyValue = ""
|
|
||||||
@State private var modernValue = ""
|
|
||||||
@State private var statusMessage = ""
|
|
||||||
@State private var isLoading = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("The Scenario") {
|
|
||||||
Text("A conditional migration only runs when the app version is below a threshold.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 1: Setup Legacy Data") {
|
|
||||||
Text("Save a legacy app mode string that will migrate only if the version condition is met.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
TextField("Legacy App Mode", text: $legacyValue)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
|
|
||||||
Button(action: saveToLegacy) {
|
|
||||||
Label("Save Legacy Mode", systemImage: "square.and.arrow.down")
|
|
||||||
}
|
|
||||||
.disabled(legacyValue.isEmpty || isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 2: Trigger Conditional Migration") {
|
|
||||||
Text("Load the modern key to run the conditional migration.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: loadFromModern) {
|
|
||||||
Label("Load Modern Mode", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
.disabled(isLoading)
|
|
||||||
|
|
||||||
if !modernValue.isEmpty {
|
|
||||||
LabeledContent("Migrated Mode", value: modernValue)
|
|
||||||
.foregroundStyle(Color.Status.success)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !statusMessage.isEmpty {
|
|
||||||
Section {
|
|
||||||
Text(statusMessage)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Conditional Migration")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveToLegacy() {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let key = StorageKeys.LegacyAppModeKey()
|
|
||||||
try await StorageRouter.shared.set(legacyValue, for: key)
|
|
||||||
statusMessage = "✓ Saved legacy app mode"
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromModern() {
|
|
||||||
isLoading = true
|
|
||||||
statusMessage = "Loading modern mode..."
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let key = StorageKeys.ModernAppModeKey()
|
|
||||||
let value = try await StorageRouter.shared.get(key)
|
|
||||||
modernValue = value
|
|
||||||
statusMessage = "✓ Conditional migration complete."
|
|
||||||
} catch StorageError.notFound {
|
|
||||||
statusMessage = "Modern key is empty (and no legacy data found)."
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -37,9 +37,6 @@ struct EncryptedStorageDemo: View {
|
|||||||
Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000)
|
Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
||||||
IterationWarningView()
|
|
||||||
.padding(.top, Design.Spacing.xSmall)
|
|
||||||
|
|
||||||
Text("Higher iterations = more secure but slower")
|
Text("Higher iterations = more secure but slower")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -116,6 +113,7 @@ struct EncryptedStorageDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone Only")
|
LabeledContent("Platform", value: "Phone Only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Encrypted Storage")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
@ -213,11 +211,3 @@ struct EncryptedStorageDemo: View {
|
|||||||
EncryptedStorageDemo()
|
EncryptedStorageDemo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct IterationWarningView: View {
|
|
||||||
var body: some View {
|
|
||||||
Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(Color.Status.warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -83,7 +83,6 @@ struct FileSystemDemo: View {
|
|||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
Section("App Group Storage") {
|
Section("App Group Storage") {
|
||||||
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
|
Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@ -114,7 +113,6 @@ struct FileSystemDemo: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
if let profile = storedProfile {
|
if let profile = storedProfile {
|
||||||
Section("Retrieved Profile") {
|
Section("Retrieved Profile") {
|
||||||
@ -157,6 +155,7 @@ struct FileSystemDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone + Watch Sync")
|
LabeledContent("Platform", value: "Phone + Watch Sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("File System")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -102,6 +102,7 @@ struct KeychainDemo: View {
|
|||||||
LabeledContent("Platform", value: "Phone Only")
|
LabeledContent("Platform", value: "Phone Only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Keychain")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -13,13 +13,12 @@ struct MigrationDemo: View {
|
|||||||
Section("The Scenario") {
|
Section("The Scenario") {
|
||||||
Text("Imagine you have an old version of the app that stored a User ID in plain UserDefaults. Now, you want to move it to the secure Keychain automatically without the user noticing.")
|
Text("Imagine you have an old version of the app that stored a User ID in plain UserDefaults. Now, you want to move it to the secure Keychain automatically without the user noticing.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 1: Setup Legacy Data") {
|
Section("Step 1: Setup Legacy Data") {
|
||||||
Text("First, save a value to the 'legacy' key in UserDefaults.")
|
Text("First, save a value to the 'legacy' key in UserDefaults.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
TextField("Legacy Value", text: $legacyValue)
|
TextField("Legacy Value", text: $legacyValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
@ -31,9 +30,8 @@ struct MigrationDemo: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 2: Trigger Migration") {
|
Section("Step 2: Trigger Migration") {
|
||||||
Text("Now, attempt to load from the 'modern' Keychain key. It will automatically check the legacy key, move the data, and delete the old record using the new migration protocol.")
|
Text("Now, attempt to load from the 'modern' Keychain key. It will automatically check the legacy key, move the data, and delete the old record.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: loadFromModern) {
|
Button(action: loadFromModern) {
|
||||||
Label("Load from Modern (Keychain)", systemImage: "sparkles")
|
Label("Load from Modern (Keychain)", systemImage: "sparkles")
|
||||||
@ -42,18 +40,17 @@ struct MigrationDemo: View {
|
|||||||
|
|
||||||
if !modernValue.isEmpty {
|
if !modernValue.isEmpty {
|
||||||
LabeledContent("Migrated Value", value: modernValue)
|
LabeledContent("Migrated Value", value: modernValue)
|
||||||
.foregroundStyle(Color.Status.success)
|
.foregroundStyle(.green)
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Step 3: Proactive Sweep (Drain)") {
|
Section("Step 3: Proactive Sweep (Drain)") {
|
||||||
Text("Even if the modern key already has data, you can force a sweep of legacy sources. Try saving a new value to Legacy, then click Force Migration.")
|
Text("Even if the modern key already has data, you can force a 'Sweep' of legacy sources. Try saving a NEW value to Legacy, then click Drain.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: runManualMigration) {
|
Button(action: runManualMigration) {
|
||||||
Label("Force Migration", systemImage: "arrow.up.circle.badge.clock")
|
Label("Drain Migration Sources", systemImage: "arrow.up.circle.badge.clock")
|
||||||
}
|
}
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
@ -61,7 +58,6 @@ struct MigrationDemo: View {
|
|||||||
Section("Step 4: Verify Cleanup") {
|
Section("Step 4: Verify Cleanup") {
|
||||||
Text("Check if the data was actually removed from UserDefaults after migration.")
|
Text("Check if the data was actually removed from UserDefaults after migration.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: checkLegacyExists) {
|
Button(action: checkLegacyExists) {
|
||||||
Label("Check Legacy Exists?", systemImage: "magnifyingglass")
|
Label("Check Legacy Exists?", systemImage: "magnifyingglass")
|
||||||
@ -73,7 +69,7 @@ struct MigrationDemo: View {
|
|||||||
Section {
|
Section {
|
||||||
Text(statusMessage)
|
Text(statusMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
|
.foregroundStyle(statusMessage.contains("Error") ? .red : .blue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,14 +113,14 @@ struct MigrationDemo: View {
|
|||||||
|
|
||||||
private func runManualMigration() {
|
private func runManualMigration() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
statusMessage = "Running manual migration..."
|
statusMessage = "Draining migration sources..."
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let key = StorageKeys.ModernMigrationDestinationKey()
|
let key = StorageKeys.ModernMigrationDestinationKey()
|
||||||
_ = try await StorageRouter.shared.forceMigration(for: key)
|
try await StorageRouter.shared.migrate(for: key)
|
||||||
// Refresh modern value display
|
// Refresh modern value display
|
||||||
modernValue = try await StorageRouter.shared.get(key)
|
modernValue = try await StorageRouter.shared.get(key)
|
||||||
statusMessage = "✓ Manual migration complete. Legacy data drained into Keychain."
|
statusMessage = "✓ Proactive migration complete. Legacy data drained into Keychain."
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
statusMessage = "Error: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MigrationHubView: View {
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Migration Demos") {
|
|
||||||
NavigationLink("Simple Legacy Migration") {
|
|
||||||
MigrationDemo()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink("Transforming Migration") {
|
|
||||||
TransformingMigrationDemo()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink("Aggregating Migration") {
|
|
||||||
AggregatingMigrationDemo()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink("Conditional Migration") {
|
|
||||||
ConditionalMigrationDemo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Overview") {
|
|
||||||
Text("Explore migrations that move legacy values into modern storage with transformation, aggregation, and conditional rules.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Migrations")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationStack {
|
|
||||||
MigrationHubView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -103,6 +103,7 @@ struct PlatformSyncDemo: View {
|
|||||||
LabeledContent("Max Auto-Sync Size", value: "100 KB")
|
LabeledContent("Max Auto-Sync Size", value: "100 KB")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Platform & Sync")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import LocalData
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct TransformingMigrationDemo: View {
|
|
||||||
@State private var legacyValue = ""
|
|
||||||
@State private var modernValue = ""
|
|
||||||
@State private var statusMessage = ""
|
|
||||||
@State private var isLoading = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("The Scenario") {
|
|
||||||
Text("A legacy profile name stored as a single string is migrated into a structured model with first and last names.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 1: Setup Legacy Data") {
|
|
||||||
Text("Save a full name into the legacy UserDefaults key.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
TextField("Legacy Full Name", text: $legacyValue)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
|
|
||||||
Button(action: saveToLegacy) {
|
|
||||||
Label("Save Legacy Name", systemImage: "person.crop.circle.badge.plus")
|
|
||||||
}
|
|
||||||
.disabled(legacyValue.isEmpty || isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Step 2: Trigger Migration") {
|
|
||||||
Text("Load the modern key to transform the legacy value into a structured profile.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
|
|
||||||
Button(action: loadFromModern) {
|
|
||||||
Label("Load Modern Profile", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
.disabled(isLoading)
|
|
||||||
|
|
||||||
if !modernValue.isEmpty {
|
|
||||||
LabeledContent("Migrated Name", value: modernValue)
|
|
||||||
.foregroundStyle(Color.Status.success)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !statusMessage.isEmpty {
|
|
||||||
Section {
|
|
||||||
Text(statusMessage)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? Color.Status.error : Color.Status.info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Transforming Migration")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveToLegacy() {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let key = StorageKeys.LegacyProfileNameKey()
|
|
||||||
try await StorageRouter.shared.set(legacyValue, for: key)
|
|
||||||
statusMessage = "✓ Saved legacy name \(legacyValue)"
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromModern() {
|
|
||||||
isLoading = true
|
|
||||||
statusMessage = "Loading modern profile..."
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let key = StorageKeys.ModernProfileNameKey()
|
|
||||||
let value = try await StorageRouter.shared.get(key)
|
|
||||||
modernValue = format(value)
|
|
||||||
statusMessage = "✓ Migration complete."
|
|
||||||
} catch StorageError.notFound {
|
|
||||||
statusMessage = "Modern key is empty (and no legacy data found)."
|
|
||||||
} catch {
|
|
||||||
statusMessage = "Error: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func format(_ value: ProfileName) -> String {
|
|
||||||
let trimmedFirst = value.firstName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let trimmedLast = value.lastName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmedLast.isEmpty {
|
|
||||||
return trimmedFirst
|
|
||||||
}
|
|
||||||
return "\(trimmedFirst) \(trimmedLast)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -125,6 +125,7 @@ struct UserDefaultsDemo: View {
|
|||||||
LabeledContent("Sync Policy", value: "Automatic Small")
|
LabeledContent("Sync Policy", value: "Automatic Small")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("UserDefaults")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public enum StorageServiceIdentifiers {
|
|||||||
|
|
||||||
public static var appGroupIdentifier: String {
|
public static var appGroupIdentifier: String {
|
||||||
let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ??
|
let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ??
|
||||||
"group.\(bundleIdentifier)"
|
"group.\(bundleIdentifier.lowercased())"
|
||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user