Compare commits
7 Commits
58dedb5066
...
d95e5660ef
| Author | SHA1 | Date | |
|---|---|---|---|
| d95e5660ef | |||
| cb188829f6 | |||
| 9a6b91b75b | |||
| eb911a5000 | |||
| 84c277f2ec | |||
| 5823a9bdc3 | |||
| e40205ef89 |
18
README.md
18
README.md
@ -268,7 +268,9 @@ try await StorageRouter.shared.forceMigration(for: StorageKey.modernToken)
|
|||||||
#### Automated Startup Sweep
|
#### Automated Startup Sweep
|
||||||
When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog.
|
When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!IMPORTANT]
|
||||||
|
> **Catalog Enforcement**: Once any catalog is registered, *all* storage operations require the key to be in a registered catalog. Any unregistered key will throw `StorageError.unregisteredKey`.
|
||||||
|
>
|
||||||
> **Modular Registration**: `registerCatalog` is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.
|
> **Modular Registration**: `registerCatalog` is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
@ -460,6 +462,20 @@ For end-to-end iOS + watchOS setup (including a launch-order-safe handshake), se
|
|||||||
## Testing
|
## Testing
|
||||||
- Unit tests use Swift Testing (`Testing` package)
|
- Unit tests use Swift Testing (`Testing` package)
|
||||||
|
|
||||||
|
## DocC Documentation
|
||||||
|
|
||||||
|
DocC uses the SwiftPM-generated workspace under `.swiftpm/xcode/`.
|
||||||
|
|
||||||
|
Build the documentation archive from the package root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./Documentation/build-docc.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Docs live in two places:
|
||||||
|
- `Sources/LocalData/Documentation.docc` (DocC guides and Home page)
|
||||||
|
- `Documentation/` (additional reference docs)
|
||||||
|
|
||||||
## Storage Audit
|
## Storage Audit
|
||||||
|
|
||||||
LocalData can generate a catalog of all configured storage keys, even if no data has been written yet. This is useful for security reviews and compliance.
|
LocalData can generate a catalog of all configured storage keys, even if no data has been written yet. This is useful for security reviews and compliance.
|
||||||
|
|||||||
@ -1,23 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Renders audit reports for storage key catalogs and registries.
|
||||||
public struct StorageAuditReport: Sendable {
|
public struct StorageAuditReport: Sendable {
|
||||||
|
/// Returns descriptors for all keys in a catalog.
|
||||||
|
///
|
||||||
|
/// - Parameter catalog: Catalog containing keys to describe.
|
||||||
|
/// - Returns: An array of ``StorageKeyDescriptor`` values.
|
||||||
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
|
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
|
||||||
catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
|
catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a text report for a catalog.
|
||||||
|
///
|
||||||
|
/// - Parameter catalog: Catalog containing keys to render.
|
||||||
|
/// - Returns: A newline-delimited report string.
|
||||||
public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
|
public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
|
||||||
renderText(items(for: catalog))
|
renderText(items(for: catalog))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a text report for a list of type-erased keys.
|
||||||
|
///
|
||||||
|
/// - Parameter entries: The keys to render.
|
||||||
|
/// - Returns: A newline-delimited report string.
|
||||||
public static func renderText(_ entries: [AnyStorageKey]) -> String {
|
public static func renderText(_ entries: [AnyStorageKey]) -> String {
|
||||||
renderText(entries.map(\.descriptor))
|
renderText(entries.map(\.descriptor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a text report for the global registry on the shared router.
|
||||||
|
///
|
||||||
|
/// - Returns: A newline-delimited report string.
|
||||||
public static func renderGlobalRegistry() async -> String {
|
public static func renderGlobalRegistry() async -> String {
|
||||||
let entries = await StorageRouter.shared.allRegisteredEntries()
|
let entries = await StorageRouter.shared.allRegisteredEntries()
|
||||||
return renderText(entries)
|
return renderText(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a text report for the global registry grouped by catalog.
|
||||||
|
///
|
||||||
|
/// - Returns: A report string grouped by catalog name.
|
||||||
public static func renderGlobalRegistryGrouped() async -> String {
|
public static func renderGlobalRegistryGrouped() async -> String {
|
||||||
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
|
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
|
||||||
var reportLines: [String] = []
|
var reportLines: [String] = []
|
||||||
@ -34,6 +53,10 @@ public struct StorageAuditReport: Sendable {
|
|||||||
return reportLines.joined(separator: "\n")
|
return reportLines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a text report from storage key descriptors.
|
||||||
|
///
|
||||||
|
/// - Parameter items: The descriptors to render.
|
||||||
|
/// - Returns: A newline-delimited report string.
|
||||||
public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
|
public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
|
||||||
let lines = items.map { item in
|
let lines = items.map { item in
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Configuration for the EncryptionHelper.
|
/// Configuration for the encryption system.
|
||||||
public struct EncryptionConfiguration: Sendable {
|
public struct EncryptionConfiguration: Sendable {
|
||||||
|
/// Keychain service for the master key.
|
||||||
public let masterKeyService: String
|
public let masterKeyService: String
|
||||||
|
/// Keychain account for the master key.
|
||||||
public let masterKeyAccount: String
|
public let masterKeyAccount: String
|
||||||
|
/// Master key length in bytes.
|
||||||
public let masterKeyLength: Int
|
public let masterKeyLength: Int
|
||||||
|
/// Default HKDF info string.
|
||||||
public let defaultHKDFInfo: String
|
public let defaultHKDFInfo: String
|
||||||
|
/// PBKDF2 iteration count.
|
||||||
public let pbkdf2Iterations: Int
|
public let pbkdf2Iterations: Int
|
||||||
|
|
||||||
|
/// Creates an encryption configuration.
|
||||||
public init(
|
public init(
|
||||||
masterKeyService: String = "LocalData",
|
masterKeyService: String = "LocalData",
|
||||||
masterKeyAccount: String = "MasterKey",
|
masterKeyAccount: String = "MasterKey",
|
||||||
@ -22,5 +28,6 @@ public struct EncryptionConfiguration: Sendable {
|
|||||||
self.pbkdf2Iterations = pbkdf2Iterations
|
self.pbkdf2Iterations = pbkdf2Iterations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default encryption configuration.
|
||||||
public static let `default` = EncryptionConfiguration()
|
public static let `default` = EncryptionConfiguration()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,12 @@ public struct FileStorageConfiguration: Sendable {
|
|||||||
/// Primarily used for testing isolation.
|
/// Primarily used for testing isolation.
|
||||||
public let baseURL: URL?
|
public let baseURL: URL?
|
||||||
|
|
||||||
|
/// Creates a file storage configuration.
|
||||||
public init(subDirectory: String? = nil, baseURL: URL? = nil) {
|
public init(subDirectory: String? = nil, baseURL: URL? = nil) {
|
||||||
self.subDirectory = subDirectory
|
self.subDirectory = subDirectory
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default file storage configuration.
|
||||||
public static let `default` = FileStorageConfiguration()
|
public static let `default` = FileStorageConfiguration()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ public struct StorageConfiguration: Sendable {
|
|||||||
/// The default App Group identifier to use if none is specified in a StorageKey.
|
/// The default App Group identifier to use if none is specified in a StorageKey.
|
||||||
public let defaultAppGroupIdentifier: String?
|
public let defaultAppGroupIdentifier: String?
|
||||||
|
|
||||||
|
/// Creates a configuration with optional defaults.
|
||||||
public init(
|
public init(
|
||||||
defaultKeychainService: String? = nil,
|
defaultKeychainService: String? = nil,
|
||||||
defaultAppGroupIdentifier: String? = nil
|
defaultAppGroupIdentifier: String? = nil
|
||||||
@ -17,5 +18,6 @@ public struct StorageConfiguration: Sendable {
|
|||||||
self.defaultAppGroupIdentifier = defaultAppGroupIdentifier
|
self.defaultAppGroupIdentifier = defaultAppGroupIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default configuration with no predefined identifiers.
|
||||||
public static let `default` = StorageConfiguration()
|
public static let `default` = StorageConfiguration()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,11 @@ public struct SyncConfiguration: Sendable {
|
|||||||
/// Maximum data size for automatic sync in bytes.
|
/// Maximum data size for automatic sync in bytes.
|
||||||
public let maxAutoSyncSize: Int
|
public let maxAutoSyncSize: Int
|
||||||
|
|
||||||
|
/// Creates a sync configuration.
|
||||||
public init(maxAutoSyncSize: Int = 100_000) {
|
public init(maxAutoSyncSize: Int = 100_000) {
|
||||||
self.maxAutoSyncSize = maxAutoSyncSize
|
self.maxAutoSyncSize = maxAutoSyncSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default sync configuration.
|
||||||
public static let `default` = SyncConfiguration()
|
public static let `default` = SyncConfiguration()
|
||||||
}
|
}
|
||||||
|
|||||||
62
Sources/LocalData/Documentation.docc/GettingStarted.md
Normal file
62
Sources/LocalData/Documentation.docc/GettingStarted.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
Follow this guide to define keys, configure LocalData, and register catalogs for auditing.
|
||||||
|
|
||||||
|
## 1) Define keys
|
||||||
|
|
||||||
|
Keys are static, typed constants. Do not use dynamic key names.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import LocalData
|
||||||
|
|
||||||
|
extension StorageKey where Value == String {
|
||||||
|
static let userToken = StorageKey(
|
||||||
|
name: "user_token",
|
||||||
|
domain: .keychain(service: "com.myapp"),
|
||||||
|
owner: "Auth",
|
||||||
|
description: "Current user auth token."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Configure the router (optional)
|
||||||
|
|
||||||
|
Set defaults once at app launch to avoid repeating service identifiers.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let storageConfig = StorageConfiguration(
|
||||||
|
defaultKeychainService: "com.myapp.keychain",
|
||||||
|
defaultAppGroupIdentifier: "group.com.myapp"
|
||||||
|
)
|
||||||
|
await StorageRouter.shared.updateStorageConfiguration(storageConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Register catalogs
|
||||||
|
|
||||||
|
Catalogs are mandatory if you want auditing and duplicate detection.
|
||||||
|
|
||||||
|
> Important:
|
||||||
|
> Once any catalog is registered, *all* storage operations require the key to be present in a registered catalog.
|
||||||
|
> Unregistered keys will throw `StorageError.unregisteredKey`.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct AuthCatalog: StorageKeyCatalog {
|
||||||
|
let allKeys: [AnyStorageKey] = [
|
||||||
|
.key(StorageKey.userToken)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try await StorageRouter.shared.registerCatalog(AuthCatalog())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) Store and read values
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await StorageRouter.shared.set("token", for: .userToken)
|
||||||
|
let token = try await StorageRouter.shared.get(.userToken)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Read <doc:KeyAndCatalogDiscipline> to avoid audit gaps.
|
||||||
|
- Add migrations in <doc:Migrations>.
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
# Key and Catalog Discipline
|
||||||
|
|
||||||
|
This package is designed for static, discoverable keys. Treat your key catalog as your storage manifest.
|
||||||
|
|
||||||
|
## Static keys only
|
||||||
|
|
||||||
|
Do not generate dynamic key names at runtime. If a value can exist, it should have a corresponding
|
||||||
|
static ``StorageKey`` definition. This keeps audits accurate and prevents invisible storage usage.
|
||||||
|
|
||||||
|
If you must represent multiple instances (for example, per-account settings), create a static key
|
||||||
|
for each supported slot or store a dictionary keyed by account ID inside a single ``StorageKey``.
|
||||||
|
|
||||||
|
## Avoid audit gaps
|
||||||
|
|
||||||
|
Catalog registration enforces correctness:
|
||||||
|
|
||||||
|
- Missing descriptions are rejected.
|
||||||
|
- Duplicate key names across catalogs are rejected.
|
||||||
|
- Unregistered keys throw ``StorageError/unregisteredKey(_:)`` at runtime.
|
||||||
|
|
||||||
|
> Important:
|
||||||
|
> Once any catalog is registered, *all* storage operations require keys to be registered.
|
||||||
|
> If you add a new module or feature, you must register its catalog before using its keys.
|
||||||
|
|
||||||
|
If you skip catalog registration, LocalData cannot guarantee that all writes are auditable.
|
||||||
|
|
||||||
|
## Modular catalogs
|
||||||
|
|
||||||
|
Split catalogs by feature or module and register them incrementally:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await StorageRouter.shared.registerCatalog(AuthCatalog())
|
||||||
|
try await StorageRouter.shared.registerCatalog(ProfileCatalog())
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration is additive. A collision in key names across catalogs throws an error.
|
||||||
|
|
||||||
|
## Recommended workflow
|
||||||
|
|
||||||
|
1. Define keys in `StorageKeys/` per feature.
|
||||||
|
2. Create a catalog for each feature.
|
||||||
|
3. Register all catalogs on app launch.
|
||||||
|
4. Run ``StorageAuditReport`` to verify output.
|
||||||
47
Sources/LocalData/Documentation.docc/LocalData.md
Normal file
47
Sources/LocalData/Documentation.docc/LocalData.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# ``LocalData``
|
||||||
|
|
||||||
|
@Metadata {
|
||||||
|
@TechnologyRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalData is a typed, auditable storage layer that unifies UserDefaults, Keychain, and file storage under a single API. It favors static, discoverable keys and explicit catalogs so teams can reason about what is stored, where it lives, and how it is secured.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
LocalData revolves around three concepts:
|
||||||
|
|
||||||
|
- ``StorageKey`` defines storage metadata for a single value.
|
||||||
|
- ``StorageRouter`` coordinates serialization, security, and routing.
|
||||||
|
- ``StorageKeyCatalog`` registers keys for auditing and migration.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
### Start Here
|
||||||
|
|
||||||
|
- <doc:GettingStarted>
|
||||||
|
- <doc:KeyAndCatalogDiscipline>
|
||||||
|
|
||||||
|
### Migration Guides
|
||||||
|
|
||||||
|
- <doc:Migrations>
|
||||||
|
|
||||||
|
### Sync Guides
|
||||||
|
|
||||||
|
- <doc:WatchSync>
|
||||||
|
|
||||||
|
### Security Guides
|
||||||
|
|
||||||
|
- <doc:Security>
|
||||||
|
|
||||||
|
### Testing Guides
|
||||||
|
|
||||||
|
- <doc:Testing>
|
||||||
|
|
||||||
|
### Reference
|
||||||
|
|
||||||
|
- ``StorageRouter``
|
||||||
|
- ``StorageKey``
|
||||||
|
- ``StorageKeyCatalog``
|
||||||
|
- ``StorageDomain``
|
||||||
|
- ``SecurityPolicy``
|
||||||
|
- ``StorageError``
|
||||||
72
Sources/LocalData/Documentation.docc/Migrations.md
Normal file
72
Sources/LocalData/Documentation.docc/Migrations.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Migrations
|
||||||
|
|
||||||
|
LocalData supports lazy and proactive migrations to move data from legacy keys to modern keys.
|
||||||
|
|
||||||
|
## Lazy migrations (on read)
|
||||||
|
|
||||||
|
Attach a migration to a key. If the destination is missing, `StorageRouter.get(_:)` will run it.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
extension StorageKey where Value == String {
|
||||||
|
static let legacyToken = StorageKey(
|
||||||
|
name: "legacy_token",
|
||||||
|
domain: .userDefaults(suite: nil),
|
||||||
|
security: .none,
|
||||||
|
owner: "Auth",
|
||||||
|
description: "Legacy token."
|
||||||
|
)
|
||||||
|
|
||||||
|
static let userToken = StorageKey(
|
||||||
|
name: "user_token",
|
||||||
|
domain: .keychain(service: "com.myapp"),
|
||||||
|
owner: "Auth",
|
||||||
|
description: "Modern token.",
|
||||||
|
migration: { destination in
|
||||||
|
AnyStorageMigration(
|
||||||
|
SimpleLegacyMigration(
|
||||||
|
destinationKey: destination,
|
||||||
|
sourceKey: .key(StorageKey.legacyToken)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proactive sweeps
|
||||||
|
|
||||||
|
Run migrations at startup to drain legacy values:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await StorageRouter.shared.registerCatalog(AuthCatalog(), migrateImmediately: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transforming migrations
|
||||||
|
|
||||||
|
Use `DefaultTransformingMigration` when types change.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let migration = DefaultTransformingMigration(
|
||||||
|
destinationKey: StorageKey.userAge,
|
||||||
|
sourceKey: StorageKey.legacyAgeString
|
||||||
|
) { value in
|
||||||
|
guard let intValue = Int(value) else {
|
||||||
|
throw MigrationError.transformationFailed("Invalid age")
|
||||||
|
}
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aggregating migrations
|
||||||
|
|
||||||
|
Combine multiple legacy values into one destination:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let migration = DefaultAggregatingMigration(
|
||||||
|
destinationKey: StorageKey.profileSummary,
|
||||||
|
sourceKeys: [.key(StorageKey.firstName), .key(StorageKey.lastName)]
|
||||||
|
) { sources in
|
||||||
|
let parts = sources.compactMap { $0.value as? String }
|
||||||
|
return parts.joined(separator: " ")
|
||||||
|
}
|
||||||
|
```
|
||||||
61
Sources/LocalData/Documentation.docc/Security.md
Normal file
61
Sources/LocalData/Documentation.docc/Security.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Security is declared per key using ``SecurityPolicy``.
|
||||||
|
|
||||||
|
## Recommended default
|
||||||
|
|
||||||
|
Use the default security policy unless you have a specific reason not to:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let key = StorageKey(
|
||||||
|
name: "secure_value",
|
||||||
|
domain: .fileSystem(directory: .documents),
|
||||||
|
owner: "Security",
|
||||||
|
description: "Sensitive value stored with recommended policy."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keychain security
|
||||||
|
|
||||||
|
Store data directly in Keychain with accessibility and access control:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let key = StorageKey(
|
||||||
|
name: "token",
|
||||||
|
domain: .keychain(service: "com.myapp"),
|
||||||
|
security: .keychain(
|
||||||
|
accessibility: .afterFirstUnlock,
|
||||||
|
accessControl: .biometryAny
|
||||||
|
),
|
||||||
|
owner: "Auth",
|
||||||
|
description: "Auth token."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encrypted file storage
|
||||||
|
|
||||||
|
Use encryption for file-based storage:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let key = StorageKey(
|
||||||
|
name: "secret_file",
|
||||||
|
domain: .encryptedFileSystem(directory: .documents),
|
||||||
|
owner: "Vault",
|
||||||
|
description: "Encrypted file data."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## External key material
|
||||||
|
|
||||||
|
Register a provider and reference it in the policy:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RemoteKeyProvider: KeyMaterialProviding {
|
||||||
|
func keyMaterial(for keyName: String) async throws -> Data {
|
||||||
|
Data(repeating: 1, count: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = KeyMaterialSource(id: "remote.key")
|
||||||
|
await StorageRouter.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source)
|
||||||
|
```
|
||||||
32
Sources/LocalData/Documentation.docc/Testing.md
Normal file
32
Sources/LocalData/Documentation.docc/Testing.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Use isolated helpers and custom configurations to avoid polluting real storage.
|
||||||
|
|
||||||
|
## Use temporary file locations
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let testURL = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "LocalDataTests-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
let router = StorageRouter(
|
||||||
|
keychain: MockKeychainHelper(),
|
||||||
|
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||||
|
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testURL)),
|
||||||
|
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "LocalDataTests")!)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Group identifiers
|
||||||
|
|
||||||
|
Use a mock `StorageConfiguration` to avoid real entitlements:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
await router.updateStorageConfiguration(
|
||||||
|
StorageConfiguration(defaultAppGroupIdentifier: "group.test")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keychain in simulator
|
||||||
|
|
||||||
|
Keychain tests in the simulator often require a host app with entitlements.
|
||||||
|
If you see `-34018`, ensure the test target has a host app and keychain sharing.
|
||||||
34
Sources/LocalData/Documentation.docc/WatchSync.md
Normal file
34
Sources/LocalData/Documentation.docc/WatchSync.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Watch Sync
|
||||||
|
|
||||||
|
LocalData supports WatchConnectivity application context sync for eligible keys.
|
||||||
|
|
||||||
|
## Eligibility rules
|
||||||
|
|
||||||
|
A key syncs only when:
|
||||||
|
|
||||||
|
- `availability` is `.all` or `.phoneWithWatchSync`
|
||||||
|
- `syncPolicy` is `.manual` or `.automaticSmall`
|
||||||
|
|
||||||
|
For `.automaticSmall`, the payload must be below `SyncConfiguration.maxAutoSyncSize`.
|
||||||
|
|
||||||
|
## Bootstrapping at launch
|
||||||
|
|
||||||
|
Call this on iOS after session activation or reachability changes:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responding to watch requests
|
||||||
|
|
||||||
|
Build a snapshot and return it via `WCSession`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let snapshot = await StorageRouter.shared.syncSnapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
- Activate `WCSession` in the app, not in LocalData.
|
||||||
|
- Use a launch-order-safe handshake (watch first or phone first).
|
||||||
|
- Avoid large payloads; prefer smaller derived values for watch display.
|
||||||
@ -2,15 +2,22 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
/// Actor that handles all encryption and decryption operations.
|
/// Actor that handles all encryption and decryption operations.
|
||||||
|
///
|
||||||
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
|
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
|
||||||
actor EncryptionHelper {
|
actor EncryptionHelper {
|
||||||
|
|
||||||
|
/// Shared encryption helper instance.
|
||||||
public static let shared = EncryptionHelper()
|
public static let shared = EncryptionHelper()
|
||||||
|
|
||||||
private var configuration: EncryptionConfiguration
|
private var configuration: EncryptionConfiguration
|
||||||
private var keychain: KeychainStoring
|
private var keychain: KeychainStoring
|
||||||
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
|
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
|
||||||
|
|
||||||
|
/// Creates an encryption helper with a configuration and keychain provider.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - configuration: Encryption configuration to apply.
|
||||||
|
/// - keychain: Keychain provider for master key storage.
|
||||||
internal init(
|
internal init(
|
||||||
configuration: EncryptionConfiguration = .default,
|
configuration: EncryptionConfiguration = .default,
|
||||||
keychain: KeychainStoring = KeychainHelper.shared
|
keychain: KeychainStoring = KeychainHelper.shared
|
||||||
@ -22,12 +29,16 @@ actor EncryptionHelper {
|
|||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
/// Updates the configuration for the actor.
|
/// Updates the configuration for the actor.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: New encryption configuration.
|
||||||
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
|
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the keychain helper used for master key storage.
|
/// Updates the keychain helper used for master key storage.
|
||||||
/// Internal for testing isolation.
|
///
|
||||||
|
/// - Parameter keychain: Keychain provider to use.
|
||||||
|
/// - Note: Internal for testing isolation.
|
||||||
public func updateKeychainHelper(_ keychain: KeychainStoring) {
|
public func updateKeychainHelper(_ keychain: KeychainStoring) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
@ -35,6 +46,10 @@ actor EncryptionHelper {
|
|||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Registers a key material provider for external encryption policies.
|
/// Registers a key material provider for external encryption policies.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: The provider that supplies key material.
|
||||||
|
/// - source: Identifier used to look up the provider.
|
||||||
public func registerKeyMaterialProvider(
|
public func registerKeyMaterialProvider(
|
||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Actor that handles all file system operations.
|
/// Actor that handles all file system operations.
|
||||||
/// Provides thread-safe file reading, writing, and deletion.
|
///
|
||||||
|
/// Provides thread-safe file reading, writing, deletion, and listing for
|
||||||
|
/// app sandbox and App Group containers.
|
||||||
actor FileStorageHelper {
|
actor FileStorageHelper {
|
||||||
|
|
||||||
|
/// Shared file storage helper instance.
|
||||||
public static let shared = FileStorageHelper()
|
public static let shared = FileStorageHelper()
|
||||||
|
|
||||||
private var configuration: FileStorageConfiguration
|
private var configuration: FileStorageConfiguration
|
||||||
|
|
||||||
|
/// Creates a helper with a specific configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: File storage configuration to apply.
|
||||||
internal init(configuration: FileStorageConfiguration = .default) {
|
internal init(configuration: FileStorageConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the file storage configuration.
|
/// Updates the file storage configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: New configuration to apply.
|
||||||
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
|
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
@ -20,6 +28,14 @@ actor FileStorageHelper {
|
|||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Writes data to an App Group container.
|
/// Writes data to an App Group container.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: The data to write.
|
||||||
|
/// - directory: The base directory.
|
||||||
|
/// - fileName: The file name within the directory.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - useCompleteFileProtection: Whether to use iOS complete file protection.
|
||||||
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
||||||
public func write(
|
public func write(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
to directory: FileDirectory,
|
to directory: FileDirectory,
|
||||||
@ -76,6 +92,13 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads data from an App Group container.
|
/// Reads data from an App Group container.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - directory: The base directory.
|
||||||
|
/// - fileName: The file name within the directory.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - Returns: The file contents, or `nil` if the file doesn't exist.
|
||||||
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
||||||
public func read(
|
public func read(
|
||||||
from directory: FileDirectory,
|
from directory: FileDirectory,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@ -103,6 +126,12 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a file from an App Group container.
|
/// Deletes a file from an App Group container.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - directory: The base directory.
|
||||||
|
/// - fileName: The file name within the directory.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
||||||
public func delete(
|
public func delete(
|
||||||
from directory: FileDirectory,
|
from directory: FileDirectory,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@ -133,6 +162,12 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a file exists in an App Group container.
|
/// Checks if a file exists in an App Group container.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - directory: The base directory.
|
||||||
|
/// - fileName: The file name within the directory.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - Returns: `true` if the file exists.
|
||||||
public func exists(
|
public func exists(
|
||||||
in directory: FileDirectory,
|
in directory: FileDirectory,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@ -158,6 +193,12 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lists all files in an App Group container directory.
|
/// Lists all files in an App Group container directory.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - directory: The directory to list.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - Returns: An array of file names.
|
||||||
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
||||||
public func list(in directory: FileDirectory, appGroupIdentifier: String) throws -> [String] {
|
public func list(in directory: FileDirectory, appGroupIdentifier: String) throws -> [String] {
|
||||||
let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier)
|
let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier)
|
||||||
let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory)
|
let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory)
|
||||||
@ -180,6 +221,13 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the size of a file in an App Group container.
|
/// Gets the size of a file in an App Group container.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - directory: The base directory.
|
||||||
|
/// - fileName: The file name within the directory.
|
||||||
|
/// - appGroupIdentifier: App Group identifier.
|
||||||
|
/// - Returns: The file size in bytes, or `nil` if the file doesn't exist.
|
||||||
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
||||||
public func size(
|
public func size(
|
||||||
of directory: FileDirectory,
|
of directory: FileDirectory,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
|
|||||||
@ -2,9 +2,12 @@ import Foundation
|
|||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Actor that handles all Keychain operations in isolation.
|
/// Actor that handles all Keychain operations in isolation.
|
||||||
/// Provides thread-safe access to the iOS/watchOS Keychain.
|
///
|
||||||
|
/// Provides thread-safe access to the iOS/watchOS Keychain and conforms to
|
||||||
|
/// ``KeychainStoring`` for dependency injection and testing.
|
||||||
actor KeychainHelper: KeychainStoring {
|
actor KeychainHelper: KeychainStoring {
|
||||||
|
|
||||||
|
/// Shared keychain helper instance.
|
||||||
public static let shared = KeychainHelper()
|
public static let shared = KeychainHelper()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -12,6 +15,14 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
// MARK: - KeychainStoring Implementation
|
// MARK: - KeychainStoring Implementation
|
||||||
|
|
||||||
/// Stores data in the keychain.
|
/// Stores data in the keychain.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: The data to store.
|
||||||
|
/// - service: Keychain service identifier.
|
||||||
|
/// - key: Keychain account name.
|
||||||
|
/// - accessibility: Keychain accessibility level.
|
||||||
|
/// - accessControl: Optional access control policy.
|
||||||
|
/// - Throws: ``StorageError/keychainError(_:)`` or ``StorageError/securityApplicationFailed``.
|
||||||
public func set(
|
public func set(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
service: String,
|
service: String,
|
||||||
@ -58,6 +69,12 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves data from the keychain.
|
/// Retrieves data from the keychain.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - service: Keychain service identifier.
|
||||||
|
/// - key: Keychain account name.
|
||||||
|
/// - Returns: Stored data if present, otherwise `nil`.
|
||||||
|
/// - Throws: ``StorageError/keychainError(_:)``.
|
||||||
public func get(service: String, key: String) throws -> Data? {
|
public func get(service: String, key: String) throws -> Data? {
|
||||||
var query = baseQuery(service: service, key: key)
|
var query = baseQuery(service: service, key: key)
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
@ -81,6 +98,11 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes data from the keychain.
|
/// Deletes data from the keychain.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - service: Keychain service identifier.
|
||||||
|
/// - key: Keychain account name.
|
||||||
|
/// - Throws: ``StorageError/keychainError(_:)``.
|
||||||
public func delete(service: String, key: String) throws {
|
public func delete(service: String, key: String) throws {
|
||||||
let query = baseQuery(service: service, key: key)
|
let query = baseQuery(service: service, key: key)
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
@ -96,6 +118,12 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if an item exists in the keychain.
|
/// Checks if an item exists in the keychain.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - service: Keychain service identifier.
|
||||||
|
/// - key: Keychain account name.
|
||||||
|
/// - Returns: `true` if the item exists.
|
||||||
|
/// - Throws: ``StorageError/keychainError(_:)``.
|
||||||
public func exists(service: String, key: String) throws -> Bool {
|
public func exists(service: String, key: String) throws -> Bool {
|
||||||
var query = baseQuery(service: service, key: key)
|
var query = baseQuery(service: service, key: key)
|
||||||
query[kSecReturnData as String] = false
|
query[kSecReturnData as String] = false
|
||||||
@ -117,6 +145,9 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes all items for a given service.
|
/// Deletes all items for a given service.
|
||||||
|
///
|
||||||
|
/// - Parameter service: Keychain service identifier.
|
||||||
|
/// - Throws: ``StorageError/keychainError(_:)``.
|
||||||
public func deleteAll(service: String) throws {
|
public func deleteAll(service: String) throws {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
|||||||
@ -2,23 +2,32 @@ import Foundation
|
|||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
|
||||||
/// Actor that handles WatchConnectivity sync operations.
|
/// Actor that handles WatchConnectivity sync operations.
|
||||||
|
///
|
||||||
/// Manages data synchronization between iPhone and Apple Watch.
|
/// Manages data synchronization between iPhone and Apple Watch.
|
||||||
actor SyncHelper {
|
actor SyncHelper {
|
||||||
|
|
||||||
|
/// Shared sync helper instance.
|
||||||
public static let shared = SyncHelper()
|
public static let shared = SyncHelper()
|
||||||
|
|
||||||
private var configuration: SyncConfiguration
|
private var configuration: SyncConfiguration
|
||||||
|
|
||||||
|
/// Creates a helper with a specific configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: Sync configuration to apply.
|
||||||
internal init(configuration: SyncConfiguration = .default) {
|
internal init(configuration: SyncConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the sync configuration.
|
/// Updates the sync configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: New sync configuration.
|
||||||
public func updateConfiguration(_ configuration: SyncConfiguration) {
|
public func updateConfiguration(_ configuration: SyncConfiguration) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes the current max auto-sync size for filtering outbound payloads.
|
/// Exposes the current max auto-sync size for filtering outbound payloads.
|
||||||
|
///
|
||||||
|
/// - Returns: The maximum size in bytes for automatic sync.
|
||||||
public func maxAutoSyncSize() -> Int {
|
public func maxAutoSyncSize() -> Int {
|
||||||
configuration.maxAutoSyncSize
|
configuration.maxAutoSyncSize
|
||||||
}
|
}
|
||||||
@ -68,7 +77,8 @@ actor SyncHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if sync is available.
|
/// Checks if sync is available.
|
||||||
/// - Returns: True if WatchConnectivity is supported and active.
|
///
|
||||||
|
/// - Returns: `true` if WatchConnectivity is supported and active.
|
||||||
public func isSyncAvailable() -> Bool {
|
public func isSyncAvailable() -> Bool {
|
||||||
guard WCSession.isSupported() else { return false }
|
guard WCSession.isSupported() else { return false }
|
||||||
|
|
||||||
@ -88,6 +98,7 @@ actor SyncHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the current application context.
|
/// Gets the current application context.
|
||||||
|
///
|
||||||
/// - Returns: The current application context dictionary.
|
/// - Returns: The current application context dictionary.
|
||||||
public func currentContext() -> [String: Any] {
|
public func currentContext() -> [String: Any] {
|
||||||
guard WCSession.isSupported() else { return [:] }
|
guard WCSession.isSupported() else { return [:] }
|
||||||
@ -141,8 +152,9 @@ actor SyncHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An internal proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor.
|
/// Internal proxy class that routes WCSessionDelegate callbacks to ``SyncHelper``.
|
||||||
internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
|
internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
|
||||||
|
/// Shared delegate proxy instance.
|
||||||
static let shared = SessionDelegateProxy()
|
static let shared = SessionDelegateProxy()
|
||||||
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Actor that handles all UserDefaults operations.
|
/// Actor that handles all UserDefaults operations.
|
||||||
/// Provides thread-safe access to UserDefaults with suite support.
|
///
|
||||||
|
/// Provides thread-safe access to UserDefaults with suite and App Group support.
|
||||||
actor UserDefaultsHelper {
|
actor UserDefaultsHelper {
|
||||||
|
|
||||||
|
/// Shared helper instance.
|
||||||
public static let shared = UserDefaultsHelper()
|
public static let shared = UserDefaultsHelper()
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
/// Creates a helper with a specific `UserDefaults` instance.
|
||||||
|
///
|
||||||
|
/// - Parameter defaults: The defaults instance to use (standard by default).
|
||||||
internal init(defaults: UserDefaults = .standard) {
|
internal init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Conditional migration for app version-based migration.
|
/// Conditional migration that runs only when the app version is below a threshold.
|
||||||
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
|
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
|
||||||
|
/// Destination key for the migration.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
|
/// Minimum app version required to skip this migration.
|
||||||
public let minAppVersion: String
|
public let minAppVersion: String
|
||||||
|
/// Migration to run when the version condition is met.
|
||||||
public let fallbackMigration: AnyStorageMigration
|
public let fallbackMigration: AnyStorageMigration
|
||||||
|
|
||||||
|
/// Creates a version-gated migration.
|
||||||
public init(
|
public init(
|
||||||
destinationKey: StorageKey<Value>,
|
destinationKey: StorageKey<Value>,
|
||||||
minAppVersion: String,
|
minAppVersion: String,
|
||||||
@ -16,12 +20,24 @@ public struct AppVersionConditionalMigration<Value: Codable & Sendable>: Conditi
|
|||||||
self.fallbackMigration = fallbackMigration
|
self.fallbackMigration = fallbackMigration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether the migration should run based on the app version.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to query state.
|
||||||
|
/// - context: Migration context containing the app version.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
let isEligible = context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending
|
let isEligible = context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending
|
||||||
guard isEligible else { return false }
|
guard isEligible else { return false }
|
||||||
return try await router.shouldAllowMigration(for: destinationKey, context: context)
|
return try await router.shouldAllowMigration(for: destinationKey, context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the fallback migration when the version condition is met.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to read and write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing success or failure.
|
||||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||||
try await fallbackMigration.migrate(using: router, context: context)
|
try await fallbackMigration.migrate(using: router, context: context)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Default migration that aggregates multiple source values into one destination value.
|
||||||
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
|
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
|
||||||
|
/// Destination key for aggregated data.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
|
/// Source keys providing legacy values.
|
||||||
public let sourceKeys: [AnyStorageKey]
|
public let sourceKeys: [AnyStorageKey]
|
||||||
|
/// Async aggregation closure for source values.
|
||||||
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
|
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
|
||||||
|
|
||||||
|
/// Creates an aggregating migration with a custom aggregation closure.
|
||||||
public init(
|
public init(
|
||||||
destinationKey: StorageKey<Value>,
|
destinationKey: StorageKey<Value>,
|
||||||
sourceKeys: [AnyStorageKey],
|
sourceKeys: [AnyStorageKey],
|
||||||
@ -15,10 +20,20 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
|||||||
self.aggregateAction = aggregate
|
self.aggregateAction = aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aggregates decoded source values into the destination type.
|
||||||
|
///
|
||||||
|
/// - Parameter sources: Values fetched from ``sourceKeys``.
|
||||||
|
/// - Returns: The aggregated destination value.
|
||||||
public func aggregate(_ sources: [AnyCodable]) async throws -> Value {
|
public func aggregate(_ sources: [AnyCodable]) async throws -> Value {
|
||||||
try await aggregateAction(sources)
|
try await aggregateAction(sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether the migration should run.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to query state.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||||
return false
|
return false
|
||||||
@ -36,6 +51,12 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the migration and returns a result.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to read and write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing success or failure.
|
||||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
var sourceData: [AnyCodable] = []
|
var sourceData: [AnyCodable] = []
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Default migration that transforms a single source value into a destination value.
|
||||||
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
|
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
|
||||||
|
/// Destination key for the transformed value.
|
||||||
public let destinationKey: StorageKey<DestinationValue>
|
public let destinationKey: StorageKey<DestinationValue>
|
||||||
|
/// Source key providing the legacy value.
|
||||||
public let sourceKey: StorageKey<SourceValue>
|
public let sourceKey: StorageKey<SourceValue>
|
||||||
|
/// Async transform from source to destination.
|
||||||
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
|
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
|
||||||
|
|
||||||
|
/// Creates a transforming migration with a custom transform closure.
|
||||||
public init(
|
public init(
|
||||||
destinationKey: StorageKey<DestinationValue>,
|
destinationKey: StorageKey<DestinationValue>,
|
||||||
sourceKey: StorageKey<SourceValue>,
|
sourceKey: StorageKey<SourceValue>,
|
||||||
@ -15,10 +20,20 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
|||||||
self.transformAction = transform
|
self.transformAction = transform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transforms a source value into the destination type.
|
||||||
|
///
|
||||||
|
/// - Parameter source: The value read from ``sourceKey``.
|
||||||
|
/// - Returns: The transformed destination value.
|
||||||
public func transform(_ source: SourceValue) async throws -> DestinationValue {
|
public func transform(_ source: SourceValue) async throws -> DestinationValue {
|
||||||
try await transformAction(source)
|
try await transformAction(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether the migration should run.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to query state.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||||
return false
|
return false
|
||||||
@ -30,6 +45,12 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
|||||||
return try await router.exists(sourceKey)
|
return try await router.exists(sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the migration and returns a result.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to read and write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing success or failure.
|
||||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Simple 1:1 legacy migration.
|
/// Simple 1:1 legacy migration from a single source key.
|
||||||
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
|
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
|
||||||
|
/// Destination key for migrated data.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
|
/// Source key providing legacy data.
|
||||||
public let sourceKey: AnyStorageKey
|
public let sourceKey: AnyStorageKey
|
||||||
|
|
||||||
|
/// Creates a migration from a legacy key to a destination key.
|
||||||
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
|
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
|
||||||
self.destinationKey = destinationKey
|
self.destinationKey = destinationKey
|
||||||
self.sourceKey = sourceKey
|
self.sourceKey = sourceKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether the migration should run.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to query state.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||||
return false
|
return false
|
||||||
@ -21,6 +30,12 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
|||||||
return try await router.exists(descriptor: sourceKey.descriptor)
|
return try await router.exists(descriptor: sourceKey.descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the migration and returns a result.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: The storage router used to read and write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing success or failure.
|
||||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
var errors: [MigrationError] = []
|
var errors: [MigrationError] = []
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Type-erased `Codable` wrapper for mixed-type payloads.
|
||||||
|
///
|
||||||
|
/// - Important: `AnyCodable` is `@unchecked Sendable` because `Any` cannot be verified by the
|
||||||
|
/// compiler. Callers should only store values that are safe to pass across concurrency domains.
|
||||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
|
/// Underlying value (Bool, Int, Double, String, arrays, or dictionaries).
|
||||||
public let value: Any
|
public let value: Any
|
||||||
|
|
||||||
|
/// Wraps a value for encoding or decoding.
|
||||||
public init(_ value: Any) {
|
public init(_ value: Any) {
|
||||||
self.value = value
|
self.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes a value from a single-value container.
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
|
|
||||||
@ -27,6 +34,7 @@ public struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes the wrapped value into a single-value container.
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
|
/// Type-erased wrapper around ``StorageKey`` for catalogs and audits.
|
||||||
public struct AnyStorageKey: Sendable {
|
public struct AnyStorageKey: Sendable {
|
||||||
|
/// Snapshot of key metadata for auditing and storage operations.
|
||||||
public internal(set) var descriptor: StorageKeyDescriptor
|
public internal(set) var descriptor: StorageKeyDescriptor
|
||||||
|
/// Optional migration associated with the key.
|
||||||
public internal(set) var migration: AnyStorageMigration?
|
public internal(set) var migration: AnyStorageMigration?
|
||||||
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
||||||
|
|
||||||
|
/// Creates a type-erased key from a typed ``StorageKey``.
|
||||||
|
///
|
||||||
|
/// - Parameter key: The concrete key to erase.
|
||||||
public init<Value>(_ key: StorageKey<Value>) {
|
public init<Value>(_ key: StorageKey<Value>) {
|
||||||
self.descriptor = .from(key)
|
self.descriptor = .from(key)
|
||||||
self.migration = key.migration
|
self.migration = key.migration
|
||||||
@ -21,6 +27,9 @@ public struct AnyStorageKey: Sendable {
|
|||||||
self.migrateAction = migrateAction
|
self.migrateAction = migrateAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience factory for creating a type-erased key.
|
||||||
|
///
|
||||||
|
/// - Parameter key: The concrete key to erase.
|
||||||
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
||||||
AnyStorageKey(key)
|
AnyStorageKey(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Type-erased wrapper for StorageMigration to match AnyStorageKey patterns.
|
/// Type-erased wrapper for ``StorageMigration`` for use in catalogs and registrations.
|
||||||
public struct AnyStorageMigration: Sendable {
|
public struct AnyStorageMigration: Sendable {
|
||||||
|
/// Descriptor for the migration destination key.
|
||||||
public let destinationDescriptor: StorageKeyDescriptor
|
public let destinationDescriptor: StorageKeyDescriptor
|
||||||
|
|
||||||
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
|
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
|
||||||
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
|
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
|
||||||
|
|
||||||
|
/// Creates a type-erased migration from a concrete migration.
|
||||||
|
///
|
||||||
|
/// - Parameter migration: The concrete migration to wrap.
|
||||||
public init<M: StorageMigration>(_ migration: M) {
|
public init<M: StorageMigration>(_ migration: M) {
|
||||||
self.destinationDescriptor = .from(migration.destinationKey)
|
self.destinationDescriptor = .from(migration.destinationKey)
|
||||||
self.shouldMigrateAction = { @Sendable router, context in
|
self.shouldMigrateAction = { @Sendable router, context in
|
||||||
@ -17,10 +21,22 @@ public struct AnyStorageMigration: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Evaluates whether the migration should run for the given context.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: Storage router used to query state.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
try await shouldMigrateAction(router, context)
|
try await shouldMigrateAction(router, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the migration and returns its result.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: Storage router used to read and write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing success or failure.
|
||||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||||
try await migrateAction(router, context)
|
try await migrateAction(router, context)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// File system directory for file-based storage.
|
||||||
public enum FileDirectory: Sendable, Hashable {
|
public enum FileDirectory: Sendable, Hashable {
|
||||||
case documents, caches, custom(URL)
|
/// App documents directory.
|
||||||
|
case documents
|
||||||
|
/// App caches directory.
|
||||||
|
case caches
|
||||||
|
/// Custom directory URL.
|
||||||
|
case custom(URL)
|
||||||
|
|
||||||
|
/// Resolves the directory to a concrete URL.
|
||||||
public func url() -> URL {
|
public func url() -> URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .documents:
|
case .documents:
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Identifier for external key material providers.
|
||||||
public struct KeyMaterialSource: Hashable, Sendable {
|
public struct KeyMaterialSource: Hashable, Sendable {
|
||||||
|
/// Stable identifier for the provider or key source.
|
||||||
public let id: String
|
public let id: String
|
||||||
|
|
||||||
|
/// Creates a new key material source identifier.
|
||||||
public init(id: String) {
|
public init(id: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Foundation
|
|||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Defines additional access control requirements for keychain items.
|
/// Defines additional access control requirements for keychain items.
|
||||||
|
///
|
||||||
/// These flags can require user authentication before accessing the item.
|
/// These flags can require user authentication before accessing the item.
|
||||||
public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
||||||
/// Requires any form of user presence (biometric or passcode).
|
/// Requires any form of user presence (biometric or passcode).
|
||||||
@ -26,9 +27,9 @@ public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
|||||||
/// If biometric changes, still accessible via passcode.
|
/// If biometric changes, still accessible via passcode.
|
||||||
case biometryCurrentSetOrDevicePasscode
|
case biometryCurrentSetOrDevicePasscode
|
||||||
|
|
||||||
/// Creates a SecAccessControl object with the specified accessibility.
|
/// Creates a `SecAccessControl` object with the specified accessibility.
|
||||||
/// - Parameter accessibility: The base accessibility level.
|
/// - Parameter accessibility: The base accessibility level.
|
||||||
/// - Returns: A configured SecAccessControl, or nil if creation fails.
|
/// - Returns: A configured `SecAccessControl`, or `nil` if creation fails.
|
||||||
func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? {
|
func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? {
|
||||||
let accessibilityValue = accessibility.cfString
|
let accessibilityValue = accessibility.cfString
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import Foundation
|
|||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Defines when a keychain item can be accessed.
|
/// Defines when a keychain item can be accessed.
|
||||||
/// Maps directly to Security framework's kSecAttrAccessible constants.
|
///
|
||||||
|
/// Maps directly to Security framework's `kSecAttrAccessible` constants.
|
||||||
public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
||||||
/// Item is only accessible while the device is unlocked.
|
/// Item is only accessible while the device is unlocked.
|
||||||
/// This is the most restrictive option for general use.
|
/// This is the most restrictive option for general use.
|
||||||
@ -56,6 +57,7 @@ public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All supported accessibility cases.
|
||||||
public static var allCases: [KeychainAccessibility] {
|
public static var allCases: [KeychainAccessibility] {
|
||||||
[
|
[
|
||||||
.whenUnlocked,
|
.whenUnlocked,
|
||||||
|
|||||||
@ -2,12 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
/// Context information available for conditional migrations.
|
/// Context information available for conditional migrations.
|
||||||
public struct MigrationContext: Sendable {
|
public struct MigrationContext: Sendable {
|
||||||
|
/// Current app version string.
|
||||||
public let appVersion: String
|
public let appVersion: String
|
||||||
|
/// Device metadata for platform checks.
|
||||||
public let deviceInfo: DeviceInfo
|
public let deviceInfo: DeviceInfo
|
||||||
|
/// Previously recorded migration timestamps keyed by storage key name.
|
||||||
public let migrationHistory: [String: Date]
|
public let migrationHistory: [String: Date]
|
||||||
|
/// Caller-provided preferences that may influence migration behavior.
|
||||||
public let userPreferences: [String: AnyCodable]
|
public let userPreferences: [String: AnyCodable]
|
||||||
|
/// System information for conditional checks.
|
||||||
public let systemInfo: SystemInfo
|
public let systemInfo: SystemInfo
|
||||||
|
|
||||||
|
/// Creates a migration context with optional overrides.
|
||||||
public init(
|
public init(
|
||||||
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
||||||
deviceInfo: DeviceInfo = .current,
|
deviceInfo: DeviceInfo = .current,
|
||||||
|
|||||||
@ -2,17 +2,26 @@ import Foundation
|
|||||||
|
|
||||||
/// Migration-specific error types.
|
/// Migration-specific error types.
|
||||||
public enum MigrationError: Error, Sendable, Equatable {
|
public enum MigrationError: Error, Sendable, Equatable {
|
||||||
|
/// Validation failed before migration could run.
|
||||||
case validationFailed(String)
|
case validationFailed(String)
|
||||||
|
/// Transformation failed while converting source to destination.
|
||||||
case transformationFailed(String)
|
case transformationFailed(String)
|
||||||
|
/// Underlying storage error occurred.
|
||||||
case storageFailed(StorageError)
|
case storageFailed(StorageError)
|
||||||
|
/// Conditional migration criteria were not met.
|
||||||
case conditionalMigrationFailed
|
case conditionalMigrationFailed
|
||||||
|
/// A migration is already in progress for the key.
|
||||||
case migrationInProgress
|
case migrationInProgress
|
||||||
|
/// No source data was found to migrate.
|
||||||
case sourceDataNotFound
|
case sourceDataNotFound
|
||||||
|
/// Source and destination types are incompatible.
|
||||||
case incompatibleTypes(String)
|
case incompatibleTypes(String)
|
||||||
|
/// Aggregation failed while combining multiple sources.
|
||||||
case aggregationFailed(String)
|
case aggregationFailed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MigrationError: LocalizedError {
|
extension MigrationError: LocalizedError {
|
||||||
|
/// Localized description for display or logging.
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .validationFailed(let message):
|
case .validationFailed(let message):
|
||||||
|
|||||||
@ -2,12 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
/// Result of a migration operation with detailed information.
|
/// Result of a migration operation with detailed information.
|
||||||
public struct MigrationResult: Sendable {
|
public struct MigrationResult: Sendable {
|
||||||
|
/// Whether the migration completed successfully.
|
||||||
public let success: Bool
|
public let success: Bool
|
||||||
|
/// Number of values migrated.
|
||||||
public let migratedCount: Int
|
public let migratedCount: Int
|
||||||
|
/// Errors captured during migration.
|
||||||
public let errors: [MigrationError]
|
public let errors: [MigrationError]
|
||||||
|
/// Additional metadata provided by the migration.
|
||||||
public let metadata: [String: AnyCodable]
|
public let metadata: [String: AnyCodable]
|
||||||
|
/// Duration of the migration in seconds.
|
||||||
public let duration: TimeInterval
|
public let duration: TimeInterval
|
||||||
|
|
||||||
|
/// Creates a migration result with optional details.
|
||||||
public init(
|
public init(
|
||||||
success: Bool,
|
success: Bool,
|
||||||
migratedCount: Int = 0,
|
migratedCount: Int = 0,
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Specifies which platforms a storage key is allowed to run on.
|
||||||
public enum PlatformAvailability: Sendable {
|
public enum PlatformAvailability: Sendable {
|
||||||
case all // iPhone + Watch (small only!)
|
/// Available on iOS and watchOS (small data only on watch).
|
||||||
case phoneOnly // iPhone only (large/sensitive)
|
case all
|
||||||
case watchOnly // Watch local only
|
/// Available only on iOS (large or sensitive data).
|
||||||
case phoneWithWatchSync // Small data for explicit sync
|
case phoneOnly
|
||||||
|
/// Available only on watchOS.
|
||||||
|
case watchOnly
|
||||||
|
/// Available on iOS and watchOS with explicit sync behavior.
|
||||||
|
case phoneWithWatchSync
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience helpers for platform checks.
|
||||||
public extension PlatformAvailability {
|
public extension PlatformAvailability {
|
||||||
|
/// Returns `true` if the key should be available on the given platform.
|
||||||
func isAvailable(on platform: Platform) -> Bool {
|
func isAvailable(on platform: Platform) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
|
|||||||
@ -2,26 +2,40 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
/// Security policy for a ``StorageKey``.
|
||||||
public enum SecurityPolicy: Equatable, Sendable {
|
public enum SecurityPolicy: Equatable, Sendable {
|
||||||
|
/// Stores data without additional security.
|
||||||
case none
|
case none
|
||||||
|
/// Encrypts data before storage using the specified policy.
|
||||||
case encrypted(EncryptionPolicy)
|
case encrypted(EncryptionPolicy)
|
||||||
|
/// Stores data directly in the Keychain with accessibility and access control options.
|
||||||
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
||||||
|
|
||||||
|
/// Recommended security policy for most sensitive data.
|
||||||
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
||||||
|
|
||||||
|
/// Encryption algorithm and key derivation settings.
|
||||||
public enum EncryptionPolicy: Equatable, Sendable {
|
public enum EncryptionPolicy: Equatable, Sendable {
|
||||||
|
/// AES-256-GCM encryption.
|
||||||
case aes256(keyDerivation: KeyDerivation)
|
case aes256(keyDerivation: KeyDerivation)
|
||||||
|
/// ChaCha20-Poly1305 encryption.
|
||||||
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
||||||
|
/// External key material with key derivation.
|
||||||
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
||||||
|
|
||||||
|
/// Recommended encryption policy for most cases.
|
||||||
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
|
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
|
||||||
|
/// Convenience for external key material with default HKDF.
|
||||||
public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
|
public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
|
||||||
.external(source: source, keyDerivation: .hkdf())
|
.external(source: source, keyDerivation: .hkdf())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Key derivation algorithms for encryption keys.
|
||||||
public enum KeyDerivation: Equatable, Sendable {
|
public enum KeyDerivation: Equatable, Sendable {
|
||||||
|
/// PBKDF2 with optional iterations and salt.
|
||||||
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
||||||
|
/// HKDF with optional salt and info.
|
||||||
case hkdf(salt: Data? = nil, info: Data? = nil)
|
case hkdf(salt: Data? = nil, info: Data? = nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Encodes and decodes values for storage.
|
||||||
public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
||||||
|
/// Encodes a value into `Data`.
|
||||||
public let encode: @Sendable (Value) throws -> Data
|
public let encode: @Sendable (Value) throws -> Data
|
||||||
|
/// Decodes a value from `Data`.
|
||||||
public let decode: @Sendable (Data) throws -> Value
|
public let decode: @Sendable (Data) throws -> Value
|
||||||
|
/// Human-readable serializer name used in audit reports.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
|
||||||
|
/// Creates a custom serializer.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - encode: Encoder for values.
|
||||||
|
/// - decode: Decoder for values.
|
||||||
|
/// - name: Display name for audit and logging.
|
||||||
public init(
|
public init(
|
||||||
encode: @escaping @Sendable (Value) throws -> Data,
|
encode: @escaping @Sendable (Value) throws -> Data,
|
||||||
decode: @escaping @Sendable (Data) throws -> Value,
|
decode: @escaping @Sendable (Data) throws -> Value,
|
||||||
@ -15,8 +25,12 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Description used by `CustomStringConvertible`.
|
||||||
public var description: String { name }
|
public var description: String { name }
|
||||||
|
|
||||||
|
/// JSON serializer using `JSONEncoder` and `JSONDecoder`.
|
||||||
|
///
|
||||||
|
/// - Returns: A serializer that encodes and decodes JSON.
|
||||||
public static var json: Serializer<Value> {
|
public static var json: Serializer<Value> {
|
||||||
Serializer<Value>(
|
Serializer<Value>(
|
||||||
encode: { try JSONEncoder().encode($0) },
|
encode: { try JSONEncoder().encode($0) },
|
||||||
@ -25,6 +39,9 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property list serializer using `PropertyListEncoder` and `PropertyListDecoder`.
|
||||||
|
///
|
||||||
|
/// - Returns: A serializer that encodes and decodes property lists.
|
||||||
public static var plist: Serializer<Value> {
|
public static var plist: Serializer<Value> {
|
||||||
Serializer<Value>(
|
Serializer<Value>(
|
||||||
encode: { try PropertyListEncoder().encode($0) },
|
encode: { try PropertyListEncoder().encode($0) },
|
||||||
@ -33,6 +50,13 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience for custom serializers.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - encode: Encoder for values.
|
||||||
|
/// - decode: Decoder for values.
|
||||||
|
/// - name: Display name for audit and logging.
|
||||||
|
/// - Returns: A serializer built from the provided closures.
|
||||||
public static func custom(
|
public static func custom(
|
||||||
encode: @escaping @Sendable (Value) throws -> Data,
|
encode: @escaping @Sendable (Value) throws -> Data,
|
||||||
decode: @escaping @Sendable (Data) throws -> Value,
|
decode: @escaping @Sendable (Data) throws -> Value,
|
||||||
@ -42,7 +66,11 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience serializers for raw data.
|
||||||
public extension Serializer where Value == Data {
|
public extension Serializer where Value == Data {
|
||||||
|
/// Serializer that passes through raw `Data`.
|
||||||
|
///
|
||||||
|
/// - Returns: A serializer that returns `Data` unchanged.
|
||||||
static var data: Serializer<Value> {
|
static var data: Serializer<Value> {
|
||||||
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
|
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Storage location for a ``StorageKey``.
|
||||||
public enum StorageDomain: Sendable, Equatable {
|
public enum StorageDomain: Sendable, Equatable {
|
||||||
|
/// Standard `UserDefaults` using the provided suite name.
|
||||||
case userDefaults(suite: String?)
|
case userDefaults(suite: String?)
|
||||||
|
/// App group `UserDefaults` using the provided group identifier.
|
||||||
case appGroupUserDefaults(identifier: String?)
|
case appGroupUserDefaults(identifier: String?)
|
||||||
|
/// Keychain storage using the provided service identifier.
|
||||||
case keychain(service: String?)
|
case keychain(service: String?)
|
||||||
|
/// File system storage in the specified directory.
|
||||||
case fileSystem(directory: FileDirectory)
|
case fileSystem(directory: FileDirectory)
|
||||||
|
/// Encrypted file system storage in the specified directory.
|
||||||
case encryptedFileSystem(directory: FileDirectory)
|
case encryptedFileSystem(directory: FileDirectory)
|
||||||
|
/// App group file storage using the group identifier and directory.
|
||||||
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
|
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,37 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown by storage operations and migrations.
|
||||||
public enum StorageError: Error, Equatable {
|
public enum StorageError: Error, Equatable {
|
||||||
case serializationFailed, deserializationFailed
|
/// Failed to encode a value.
|
||||||
|
case serializationFailed
|
||||||
|
/// Failed to decode stored data.
|
||||||
|
case deserializationFailed
|
||||||
|
/// Failed to apply or remove security for stored data.
|
||||||
case securityApplicationFailed
|
case securityApplicationFailed
|
||||||
|
/// Underlying Keychain error.
|
||||||
case keychainError(OSStatus)
|
case keychainError(OSStatus)
|
||||||
|
/// File system error description.
|
||||||
case fileError(String) // Changed from Error to String for easier Equatable conformance
|
case fileError(String) // Changed from Error to String for easier Equatable conformance
|
||||||
|
/// A phone-only key was accessed on watchOS.
|
||||||
case phoneOnlyKeyAccessedOnWatch(String)
|
case phoneOnlyKeyAccessedOnWatch(String)
|
||||||
|
/// A watch-only key was accessed on iOS.
|
||||||
case watchOnlyKeyAccessedOnPhone(String)
|
case watchOnlyKeyAccessedOnPhone(String)
|
||||||
|
/// Invalid UserDefaults suite name.
|
||||||
case invalidUserDefaultsSuite(String)
|
case invalidUserDefaultsSuite(String)
|
||||||
|
/// Invalid App Group identifier.
|
||||||
case invalidAppGroupIdentifier(String)
|
case invalidAppGroupIdentifier(String)
|
||||||
|
/// Sync payload exceeded the configured maximum size.
|
||||||
case dataTooLargeForSync
|
case dataTooLargeForSync
|
||||||
|
/// No value exists for the requested key.
|
||||||
case notFound
|
case notFound
|
||||||
|
/// The key is not registered in any catalog.
|
||||||
case unregisteredKey(String)
|
case unregisteredKey(String)
|
||||||
|
/// Duplicate key names detected during registration.
|
||||||
case duplicateRegisteredKeys([String])
|
case duplicateRegisteredKeys([String])
|
||||||
|
/// Missing or empty key description.
|
||||||
case missingDescription(String)
|
case missingDescription(String)
|
||||||
|
|
||||||
|
/// Compares two storage errors for equality.
|
||||||
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case (.serializationFailed, .serializationFailed),
|
case (.serializationFailed, .serializationFailed),
|
||||||
|
|||||||
@ -1,17 +1,47 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Typed descriptor for a single piece of persisted data.
|
||||||
|
///
|
||||||
|
/// Use `StorageKey` to define where a value is stored, how it is secured, how it is serialized,
|
||||||
|
/// and how it participates in sync and migration behaviors.
|
||||||
|
///
|
||||||
|
/// - Important: `name` should be unique within its storage domain to avoid collisions.
|
||||||
|
/// - Note: `Value` must conform to `Codable` and `Sendable`.
|
||||||
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
||||||
|
/// Unique identifier for the stored value within its domain.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
/// Storage location for the value (UserDefaults, Keychain, file system, etc.).
|
||||||
public let domain: StorageDomain
|
public let domain: StorageDomain
|
||||||
|
/// Security policy applied to stored bytes.
|
||||||
public let security: SecurityPolicy
|
public let security: SecurityPolicy
|
||||||
|
/// Serializer used to convert between `Value` and `Data`.
|
||||||
public let serializer: Serializer<Value>
|
public let serializer: Serializer<Value>
|
||||||
|
/// Owning feature or module for auditability.
|
||||||
public let owner: String
|
public let owner: String
|
||||||
|
/// Human-readable description for audit reports.
|
||||||
public let description: String
|
public let description: String
|
||||||
|
/// Platform availability constraints for reads/writes and migrations.
|
||||||
public let availability: PlatformAvailability
|
public let availability: PlatformAvailability
|
||||||
|
/// WatchConnectivity sync behavior for this key.
|
||||||
public let syncPolicy: SyncPolicy
|
public let syncPolicy: SyncPolicy
|
||||||
|
|
||||||
|
/// Lazily builds a migration using the fully initialized key.
|
||||||
|
///
|
||||||
|
/// This avoids capturing `self` during initialization and keeps the destination key consistent.
|
||||||
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
|
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
|
||||||
|
|
||||||
|
/// Creates a storage key with optional security, serializer, availability, sync, and migration.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Unique identifier for the stored value.
|
||||||
|
/// - domain: Storage location for the value.
|
||||||
|
/// - security: Security policy applied to stored bytes. Defaults to `.recommended`.
|
||||||
|
/// - serializer: Serializer used to encode/decode values. Defaults to `.json`.
|
||||||
|
/// - owner: Owning feature or module for auditability.
|
||||||
|
/// - description: Human-readable description for audit reports.
|
||||||
|
/// - availability: Platform availability constraints. Defaults to `.all`.
|
||||||
|
/// - syncPolicy: WatchConnectivity sync behavior. Defaults to `.never`.
|
||||||
|
/// - migration: Optional builder that creates a migration using this key as destination.
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
domain: StorageDomain,
|
domain: StorageDomain,
|
||||||
@ -34,6 +64,9 @@ public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
self.migrationBuilder = migration
|
self.migrationBuilder = migration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construct a migration on demand using this key as the destination.
|
||||||
|
///
|
||||||
|
/// - Returns: The migration for this key, or `nil` if none is configured.
|
||||||
public var migration: AnyStorageMigration? {
|
public var migration: AnyStorageMigration? {
|
||||||
migrationBuilder?(self)
|
migrationBuilder?(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Snapshot of a ``StorageKey`` used for audit and registration.
|
||||||
public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
||||||
|
/// Key name within its domain.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
/// Storage domain for the key.
|
||||||
public let domain: StorageDomain
|
public let domain: StorageDomain
|
||||||
|
/// Security policy applied to the key.
|
||||||
public let security: SecurityPolicy
|
public let security: SecurityPolicy
|
||||||
|
/// Serializer name used for encoding/decoding.
|
||||||
public let serializer: String
|
public let serializer: String
|
||||||
|
/// String representation of the value type.
|
||||||
public let valueType: String
|
public let valueType: String
|
||||||
|
/// Owning module or feature name.
|
||||||
public let owner: String
|
public let owner: String
|
||||||
|
/// Platform availability for the key.
|
||||||
public let availability: PlatformAvailability
|
public let availability: PlatformAvailability
|
||||||
|
/// Sync policy for WatchConnectivity.
|
||||||
public let syncPolicy: SyncPolicy
|
public let syncPolicy: SyncPolicy
|
||||||
|
/// Human-readable description for audit reports.
|
||||||
public let description: String
|
public let description: String
|
||||||
|
/// Optional catalog name the key belongs to.
|
||||||
public let catalog: String?
|
public let catalog: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@ -36,6 +47,12 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
|||||||
self.catalog = catalog
|
self.catalog = catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a descriptor from a ``StorageKey``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - key: The key to describe.
|
||||||
|
/// - catalog: Optional catalog name for audit context.
|
||||||
|
/// - Returns: A populated ``StorageKeyDescriptor``.
|
||||||
public static func from<Value>(
|
public static func from<Value>(
|
||||||
_ key: StorageKey<Value>,
|
_ key: StorageKey<Value>,
|
||||||
catalog: String? = nil
|
catalog: String? = nil
|
||||||
@ -55,6 +72,9 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a new descriptor with the catalog name set.
|
/// Returns a new descriptor with the catalog name set.
|
||||||
|
///
|
||||||
|
/// - Parameter catalogName: Catalog name to assign.
|
||||||
|
/// - Returns: A new descriptor with the catalog field set.
|
||||||
public func withCatalog(_ catalogName: String) -> StorageKeyDescriptor {
|
public func withCatalog(_ catalogName: String) -> StorageKeyDescriptor {
|
||||||
StorageKeyDescriptor(
|
StorageKeyDescriptor(
|
||||||
name: self.name,
|
name: self.name,
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Defines how a key participates in WatchConnectivity sync.
|
||||||
public enum SyncPolicy: Sendable {
|
public enum SyncPolicy: Sendable {
|
||||||
case never // Default for most
|
/// No sync behavior.
|
||||||
case manual // Manual WCSession send
|
case never
|
||||||
case automaticSmall // Auto-sync if small
|
/// Sync only when the app explicitly requests it.
|
||||||
|
case manual
|
||||||
|
/// Automatically sync when data size is below the configured threshold.
|
||||||
|
case automaticSmall
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import Foundation
|
|||||||
|
|
||||||
/// Migration protocol that combines multiple sources into a single destination.
|
/// Migration protocol that combines multiple sources into a single destination.
|
||||||
public protocol AggregatingMigration: StorageMigration {
|
public protocol AggregatingMigration: StorageMigration {
|
||||||
|
/// The set of source keys used to build the destination value.
|
||||||
var sourceKeys: [AnyStorageKey] { get }
|
var sourceKeys: [AnyStorageKey] { get }
|
||||||
|
/// Aggregates decoded source values into the destination value type.
|
||||||
|
///
|
||||||
|
/// - Parameter sources: The values fetched from ``sourceKeys``.
|
||||||
|
/// - Returns: The aggregated destination value.
|
||||||
func aggregate(_ sources: [AnyCodable]) async throws -> Value
|
func aggregate(_ sources: [AnyCodable]) async throws -> Value
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Supplies external key material for encryption policies.
|
||||||
public protocol KeyMaterialProviding: Sendable {
|
public protocol KeyMaterialProviding: Sendable {
|
||||||
|
/// Returns key material associated with the given key name.
|
||||||
func keyMaterial(for keyName: String) async throws -> Data
|
func keyMaterial(for keyName: String) async throws -> Data
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol defining the interface for Keychain operations.
|
/// Protocol defining the interface for Keychain operations.
|
||||||
/// Allows for dependency injection and mocking in tests.
|
///
|
||||||
|
/// Conformers enable dependency injection and mocking in tests.
|
||||||
public protocol KeychainStoring: Sendable {
|
public protocol KeychainStoring: Sendable {
|
||||||
|
/// Stores data for a keychain entry.
|
||||||
func set(
|
func set(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
service: String,
|
service: String,
|
||||||
@ -11,11 +13,15 @@ public protocol KeychainStoring: Sendable {
|
|||||||
accessControl: KeychainAccessControl?
|
accessControl: KeychainAccessControl?
|
||||||
) async throws
|
) async throws
|
||||||
|
|
||||||
|
/// Retrieves data for a keychain entry.
|
||||||
func get(service: String, key: String) async throws -> Data?
|
func get(service: String, key: String) async throws -> Data?
|
||||||
|
|
||||||
|
/// Deletes a keychain entry.
|
||||||
func delete(service: String, key: String) async throws
|
func delete(service: String, key: String) async throws
|
||||||
|
|
||||||
|
/// Checks if a keychain entry exists.
|
||||||
func exists(service: String, key: String) async throws -> Bool
|
func exists(service: String, key: String) async throws -> Bool
|
||||||
|
|
||||||
|
/// Deletes all keychain entries for a service.
|
||||||
func deleteAll(service: String) async throws
|
func deleteAll(service: String) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
|
/// Collection of storage keys used for registration and auditing.
|
||||||
public protocol StorageKeyCatalog: Sendable {
|
public protocol StorageKeyCatalog: Sendable {
|
||||||
|
/// Human-readable catalog name used in audit reports.
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
|
/// All keys owned by this catalog.
|
||||||
|
///
|
||||||
|
/// Use type-erased ``AnyStorageKey`` to allow heterogeneous value types.
|
||||||
var allKeys: [AnyStorageKey] { get }
|
var allKeys: [AnyStorageKey] { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StorageKeyCatalog {
|
extension StorageKeyCatalog {
|
||||||
|
/// Default catalog name derived from the type name.
|
||||||
public var name: String {
|
public var name: String {
|
||||||
let fullName = String(describing: type(of: self))
|
let fullName = String(describing: type(of: self))
|
||||||
// Simple cleanup for generic or nested names if needed,
|
// Simple cleanup for generic or nested names if needed,
|
||||||
|
|||||||
@ -1,20 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Core migration protocol with high-level methods.
|
/// Core migration protocol for moving data into a destination ``StorageKey``.
|
||||||
public protocol StorageMigration: Sendable {
|
public protocol StorageMigration: Sendable {
|
||||||
|
/// The value type produced by the migration.
|
||||||
associatedtype Value: Codable & Sendable
|
associatedtype Value: Codable & Sendable
|
||||||
|
|
||||||
/// The destination storage key where migrated data will be stored.
|
/// The destination storage key where migrated data will be stored.
|
||||||
var destinationKey: StorageKey<Value> { get }
|
var destinationKey: StorageKey<Value> { get }
|
||||||
|
|
||||||
/// Validate if migration should proceed (conditional logic).
|
/// Determines whether the migration should run for the given context.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: Storage router used to query state.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: `true` when migration should proceed.
|
||||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
|
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
|
||||||
|
|
||||||
/// Execute the migration process.
|
/// Executes the migration and returns a result describing success or failure.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - router: Storage router used to read/write values.
|
||||||
|
/// - context: Migration context for conditional checks.
|
||||||
|
/// - Returns: A ``MigrationResult`` describing the outcome.
|
||||||
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
|
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default behavior for storage migrations.
|
||||||
public extension StorageMigration {
|
public extension StorageMigration {
|
||||||
|
/// Default conditional behavior that checks platform availability and existing data.
|
||||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||||
try await router.shouldAllowMigration(for: destinationKey, context: context)
|
try await router.shouldAllowMigration(for: destinationKey, context: context)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Abstraction for basic storage operations.
|
||||||
|
///
|
||||||
|
/// Conforming types persist and retrieve values described by a ``StorageKey``.
|
||||||
public protocol StorageProviding: Sendable {
|
public protocol StorageProviding: Sendable {
|
||||||
|
/// Stores a value for the given key.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - value: The value to store.
|
||||||
|
/// - key: The storage key describing where and how to store the value.
|
||||||
func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws
|
func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws
|
||||||
|
/// Retrieves a value for the given key.
|
||||||
|
///
|
||||||
|
/// - Parameter key: The storage key to read.
|
||||||
|
/// - Returns: The stored value.
|
||||||
func get<Value>(_ key: StorageKey<Value>) async throws -> Value
|
func get<Value>(_ key: StorageKey<Value>) async throws -> Value
|
||||||
|
/// Removes a value for the given key.
|
||||||
|
///
|
||||||
|
/// - Parameter key: The storage key to remove.
|
||||||
func remove<Value>(_ key: StorageKey<Value>) async throws
|
func remove<Value>(_ key: StorageKey<Value>) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Migration protocol that supports data transformation during migration.
|
/// Migration protocol that transforms a source value into a destination value.
|
||||||
public protocol TransformingMigration: StorageMigration {
|
public protocol TransformingMigration: StorageMigration {
|
||||||
|
/// The value type stored at the source key.
|
||||||
associatedtype SourceValue: Codable & Sendable
|
associatedtype SourceValue: Codable & Sendable
|
||||||
|
|
||||||
|
/// The source key to read from during migration.
|
||||||
var sourceKey: StorageKey<SourceValue> { get }
|
var sourceKey: StorageKey<SourceValue> { get }
|
||||||
|
/// Transforms a source value into the destination value type.
|
||||||
|
///
|
||||||
|
/// - Parameter source: The value read from ``sourceKey``.
|
||||||
|
/// - Returns: The transformed value for the destination key.
|
||||||
func transform(_ source: SourceValue) async throws -> Value
|
func transform(_ source: SourceValue) async throws -> Value
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,15 @@ import Foundation
|
|||||||
|
|
||||||
/// The main storage router that coordinates all storage operations.
|
/// The main storage router that coordinates all storage operations.
|
||||||
/// Uses specialized helper actors for each storage domain.
|
/// Uses specialized helper actors for each storage domain.
|
||||||
|
/// Central coordinator for all LocalData storage operations.
|
||||||
|
///
|
||||||
|
/// `StorageRouter` orchestrates serialization, security, storage domain routing,
|
||||||
|
/// catalog validation, migrations, and WatchConnectivity sync. Use the shared
|
||||||
|
/// instance for app-wide access and register catalogs at launch to enable
|
||||||
|
/// auditability and duplicate key detection.
|
||||||
public actor StorageRouter: StorageProviding {
|
public actor StorageRouter: StorageProviding {
|
||||||
|
|
||||||
|
/// Shared router instance for app-wide storage access.
|
||||||
public static let shared = StorageRouter()
|
public static let shared = StorageRouter()
|
||||||
|
|
||||||
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
||||||
@ -16,9 +23,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private let defaults: UserDefaultsHelper
|
private let defaults: UserDefaultsHelper
|
||||||
private let sync: SyncHelper
|
private let sync: SyncHelper
|
||||||
|
|
||||||
/// Initialize a new StorageRouter.
|
/// Initializes a new router with injected helpers.
|
||||||
/// Internal for testing isolation via @testable import.
|
///
|
||||||
/// Consumers should use the `shared` singleton.
|
/// - Important: Internal for testing isolation via `@testable import`.
|
||||||
|
/// Production code should use ``StorageRouter/shared``.
|
||||||
internal init(
|
internal init(
|
||||||
keychain: KeychainStoring = KeychainHelper.shared,
|
keychain: KeychainStoring = KeychainHelper.shared,
|
||||||
encryption: EncryptionHelper = .shared,
|
encryption: EncryptionHelper = .shared,
|
||||||
@ -36,25 +44,31 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
/// Updates the encryption configuration.
|
/// Updates the encryption configuration.
|
||||||
/// > [!WARNING]
|
///
|
||||||
/// > Changing these constants in an existing app will cause the app to look for the master key
|
/// - Warning: Changing these constants in an existing app causes the app to look for
|
||||||
/// > under a new name. Previously encrypted data will be lost.
|
/// the master key under a new name. Previously encrypted data may become inaccessible.
|
||||||
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
||||||
await encryption.updateConfiguration(configuration)
|
await encryption.updateConfiguration(configuration)
|
||||||
await encryption.updateKeychainHelper(keychain)
|
await encryption.updateKeychainHelper(keychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the sync configuration.
|
/// Updates the sync configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: New sync settings, including maximum payload size.
|
||||||
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
||||||
await sync.updateConfiguration(configuration)
|
await sync.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the file storage configuration.
|
/// Updates the file storage configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: New file storage settings, including subdirectory scope.
|
||||||
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
||||||
await file.updateConfiguration(configuration)
|
await file.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the global storage configuration (defaults).
|
/// Updates the global storage configuration (defaults).
|
||||||
|
///
|
||||||
|
/// - Parameter configuration: Default identifiers for keychain and app group storage.
|
||||||
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
|
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
|
||||||
self.storageConfiguration = configuration
|
self.storageConfiguration = configuration
|
||||||
}
|
}
|
||||||
@ -62,6 +76,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Key Material Providers
|
// MARK: - Key Material Providers
|
||||||
|
|
||||||
/// Registers a key material provider for external encryption policies.
|
/// Registers a key material provider for external encryption policies.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: Object that supplies key material for encryption.
|
||||||
|
/// - source: Identifier used to look up the provider.
|
||||||
public func registerKeyMaterialProvider(
|
public func registerKeyMaterialProvider(
|
||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
@ -71,10 +89,14 @@ public actor StorageRouter: StorageProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a catalog of known storage keys for audit and validation.
|
/// Registers a catalog of known storage keys for audit and validation.
|
||||||
/// When registered, all storage operations will verify keys are listed.
|
///
|
||||||
|
/// - Important: Once any catalog is registered, *all* storage operations require keys
|
||||||
|
/// to be present in a registered catalog. Unregistered keys throw
|
||||||
|
/// ``StorageError/unregisteredKey(_:)``.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - catalog: The catalog type to register.
|
/// - catalog: The catalog type to register.
|
||||||
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
|
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
|
||||||
|
/// - Throws: ``StorageError/duplicateRegisteredKeys(_:)`` or ``StorageError/missingDescription(_:)``.
|
||||||
public func registerCatalog(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
|
public func registerCatalog(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
|
||||||
let entries = catalog.allKeys
|
let entries = catalog.allKeys
|
||||||
try validateDescription(entries)
|
try validateDescription(entries)
|
||||||
@ -111,13 +133,14 @@ public actor StorageRouter: StorageProviding {
|
|||||||
catalogRegistries
|
catalogRegistries
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all currently registered storage keys.
|
/// Returns all currently registered storage keys as a flat list.
|
||||||
public func allRegisteredEntries() -> [AnyStorageKey] {
|
public func allRegisteredEntries() -> [AnyStorageKey] {
|
||||||
Array(registeredKeys.values)
|
Array(registeredKeys.values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggers a proactive migration (sweep) for all registered storage keys.
|
/// Triggers a proactive migration (sweep) for all registered storage keys.
|
||||||
/// This "drains" any legacy data into the modern storage locations.
|
/// This "drains" any legacy data into the modern storage locations.
|
||||||
|
/// - Throws: Migration or storage errors from individual keys.
|
||||||
public func migrateAllRegisteredKeys() async throws {
|
public func migrateAllRegisteredKeys() async throws {
|
||||||
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
|
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
|
||||||
for entry in registeredKeys.values {
|
for entry in registeredKeys.values {
|
||||||
@ -127,6 +150,9 @@ public actor StorageRouter: StorageProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the last migration date for a specific key, if available.
|
/// Returns the last migration date for a specific key, if available.
|
||||||
|
///
|
||||||
|
/// - Parameter key: The key to look up.
|
||||||
|
/// - Returns: The date of the most recent successful migration.
|
||||||
public func migrationHistory<Value>(for key: StorageKey<Value>) -> Date? {
|
public func migrationHistory<Value>(for key: StorageKey<Value>) -> Date? {
|
||||||
migrationHistory[key.name]
|
migrationHistory[key.name]
|
||||||
}
|
}
|
||||||
@ -555,6 +581,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
/// Attempts to sync any registered keys that already have stored values.
|
/// Attempts to sync any registered keys that already have stored values.
|
||||||
/// This is useful for bootstrapping watch data after app launch or reconnection.
|
/// This is useful for bootstrapping watch data after app launch or reconnection.
|
||||||
|
///
|
||||||
|
/// The router only syncs keys that:
|
||||||
|
/// - Are available on watch (`.all` or `.phoneWithWatchSync`)
|
||||||
|
/// - Have a non-`.never` sync policy
|
||||||
public func syncRegisteredKeysIfNeeded() async {
|
public func syncRegisteredKeysIfNeeded() async {
|
||||||
let isAvailable = await sync.isSyncAvailable()
|
let isAvailable = await sync.isSyncAvailable()
|
||||||
guard isAvailable else {
|
guard isAvailable else {
|
||||||
@ -593,6 +623,8 @@ public actor StorageRouter: StorageProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a snapshot of syncable key data for immediate watch requests.
|
/// Builds a snapshot of syncable key data for immediate watch requests.
|
||||||
|
///
|
||||||
|
/// - Returns: A dictionary mapping key names to secured `Data` payloads.
|
||||||
public func syncSnapshot() async -> [String: Data] {
|
public func syncSnapshot() async -> [String: Data] {
|
||||||
let isAvailable = await sync.isSyncAvailable()
|
let isAvailable = await sync.isSyncAvailable()
|
||||||
guard isAvailable else {
|
guard isAvailable else {
|
||||||
|
|||||||
@ -9,11 +9,16 @@ import WatchKit
|
|||||||
|
|
||||||
/// Device information for migration context.
|
/// Device information for migration context.
|
||||||
public struct DeviceInfo: Sendable {
|
public struct DeviceInfo: Sendable {
|
||||||
|
/// Current platform (iOS, watchOS, unknown).
|
||||||
public let platform: Platform
|
public let platform: Platform
|
||||||
|
/// OS version string.
|
||||||
public let systemVersion: String
|
public let systemVersion: String
|
||||||
|
/// Device model identifier or marketing name.
|
||||||
public let model: String
|
public let model: String
|
||||||
|
/// Whether the device is a simulator.
|
||||||
public let isSimulator: Bool
|
public let isSimulator: Bool
|
||||||
|
|
||||||
|
/// Current device info derived from the running environment.
|
||||||
public static let current = DeviceInfo()
|
public static let current = DeviceInfo()
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@ -34,6 +39,7 @@ public struct DeviceInfo: Sendable {
|
|||||||
self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
|
self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a custom device info instance (useful for tests).
|
||||||
public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) {
|
public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) {
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.systemVersion = systemVersion
|
self.systemVersion = systemVersion
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import Foundation
|
|||||||
|
|
||||||
/// Internal logging utility for the LocalData package.
|
/// Internal logging utility for the LocalData package.
|
||||||
enum Logger {
|
enum Logger {
|
||||||
|
/// Enables or disables logging output.
|
||||||
static var isLoggingEnabled = true
|
static var isLoggingEnabled = true
|
||||||
|
|
||||||
|
/// Emits a debug-level log message.
|
||||||
static func debug(_ message: String) {
|
static func debug(_ message: String) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if isLoggingEnabled {
|
if isLoggingEnabled {
|
||||||
@ -12,6 +14,7 @@ enum Logger {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits an info-level log message.
|
||||||
static func info(_ message: String) {
|
static func info(_ message: String) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if isLoggingEnabled {
|
if isLoggingEnabled {
|
||||||
@ -20,6 +23,11 @@ enum Logger {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits an error-level log message.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - message: The message to log.
|
||||||
|
/// - error: Optional error to include in the output.
|
||||||
static func error(_ message: String, error: Error? = nil) {
|
static func error(_ message: String, error: Error? = nil) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
var logMessage = " {LOCAL_DATA} ❌ \(message)"
|
var logMessage = " {LOCAL_DATA} ❌ \(message)"
|
||||||
|
|||||||
@ -2,15 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
/// Utilities for common migration operations.
|
/// Utilities for common migration operations.
|
||||||
public enum MigrationUtils {
|
public enum MigrationUtils {
|
||||||
|
/// Returns `true` if a value can be transformed between the given types.
|
||||||
public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool {
|
public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool {
|
||||||
if T.self is U.Type { return true }
|
if T.self is U.Type { return true }
|
||||||
return T.self == String.self || U.self == String.self
|
return T.self == String.self || U.self == String.self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Estimates the size of a data payload in bytes.
|
||||||
public static func estimatedSize(for data: Data) -> UInt64 {
|
public static func estimatedSize(for data: Data) -> UInt64 {
|
||||||
UInt64(data.count)
|
UInt64(data.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates that source and destination descriptors are compatible.
|
||||||
public static func validateCompatibility(
|
public static func validateCompatibility(
|
||||||
source: StorageKeyDescriptor,
|
source: StorageKeyDescriptor,
|
||||||
destination: StorageKeyDescriptor
|
destination: StorageKeyDescriptor
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Supported runtime platforms for storage availability checks.
|
||||||
public enum Platform: String, CaseIterable, Sendable {
|
public enum Platform: String, CaseIterable, Sendable {
|
||||||
|
/// iOS platform.
|
||||||
case iOS = "iOS"
|
case iOS = "iOS"
|
||||||
|
/// watchOS platform.
|
||||||
case watchOS = "watchOS"
|
case watchOS = "watchOS"
|
||||||
|
/// Unknown or unsupported platform.
|
||||||
case unknown = "unknown"
|
case unknown = "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
/// System information for migration context.
|
/// System information for migration context.
|
||||||
public struct SystemInfo: Sendable {
|
public struct SystemInfo: Sendable {
|
||||||
|
/// Free disk space in bytes.
|
||||||
public let availableDiskSpace: UInt64
|
public let availableDiskSpace: UInt64
|
||||||
|
/// Physical memory in bytes.
|
||||||
public let availableMemory: UInt64
|
public let availableMemory: UInt64
|
||||||
|
/// Whether Low Power Mode is enabled.
|
||||||
public let isLowPowerModeEnabled: Bool
|
public let isLowPowerModeEnabled: Bool
|
||||||
|
|
||||||
|
/// Current system info derived from the running environment.
|
||||||
public static let current = SystemInfo()
|
public static let current = SystemInfo()
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user