Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e40205ef89
commit
5823a9bdc3
@ -3,27 +3,40 @@ import Foundation
|
||||
/// Renders audit reports for storage key catalogs and registries.
|
||||
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] {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
let entries = await StorageRouter.shared.allRegisteredEntries()
|
||||
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 {
|
||||
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
|
||||
var reportLines: [String] = []
|
||||
@ -41,6 +54,9 @@ public struct StorageAuditReport: Sendable {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let lines = items.map { item in
|
||||
var parts: [String] = []
|
||||
|
||||
@ -2,15 +2,22 @@ import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Actor that handles all encryption and decryption operations.
|
||||
///
|
||||
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
|
||||
actor EncryptionHelper {
|
||||
|
||||
/// Shared encryption helper instance.
|
||||
public static let shared = EncryptionHelper()
|
||||
|
||||
private var configuration: EncryptionConfiguration
|
||||
private var keychain: KeychainStoring
|
||||
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(
|
||||
configuration: EncryptionConfiguration = .default,
|
||||
keychain: KeychainStoring = KeychainHelper.shared
|
||||
@ -22,12 +29,16 @@ actor EncryptionHelper {
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Updates the configuration for the actor.
|
||||
///
|
||||
/// - Parameter configuration: New encryption configuration.
|
||||
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
@ -35,6 +46,10 @@ actor EncryptionHelper {
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// 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(
|
||||
_ provider: any KeyMaterialProviding,
|
||||
for source: KeyMaterialSource
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// 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 {
|
||||
|
||||
/// Shared file storage helper instance.
|
||||
public static let shared = FileStorageHelper()
|
||||
|
||||
private var configuration: FileStorageConfiguration
|
||||
|
||||
/// Creates a helper with a specific configuration.
|
||||
///
|
||||
/// - Parameter configuration: File storage configuration to apply.
|
||||
internal init(configuration: FileStorageConfiguration = .default) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Updates the file storage configuration.
|
||||
///
|
||||
/// - Parameter configuration: New configuration to apply.
|
||||
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
@ -20,6 +28,14 @@ actor FileStorageHelper {
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// 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(
|
||||
_ data: Data,
|
||||
to directory: FileDirectory,
|
||||
@ -76,6 +92,13 @@ actor FileStorageHelper {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
from directory: FileDirectory,
|
||||
fileName: String,
|
||||
@ -103,6 +126,12 @@ actor FileStorageHelper {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
from directory: FileDirectory,
|
||||
fileName: String,
|
||||
@ -133,6 +162,12 @@ actor FileStorageHelper {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
in directory: FileDirectory,
|
||||
fileName: String,
|
||||
@ -158,6 +193,12 @@ actor FileStorageHelper {
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier)
|
||||
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.
|
||||
///
|
||||
/// - 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(
|
||||
of directory: FileDirectory,
|
||||
fileName: String,
|
||||
|
||||
@ -2,9 +2,12 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// 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 {
|
||||
|
||||
/// Shared keychain helper instance.
|
||||
public static let shared = KeychainHelper()
|
||||
|
||||
private init() {}
|
||||
@ -12,6 +15,14 @@ actor KeychainHelper: KeychainStoring {
|
||||
// MARK: - KeychainStoring Implementation
|
||||
|
||||
/// 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(
|
||||
_ data: Data,
|
||||
service: String,
|
||||
@ -58,6 +69,12 @@ actor KeychainHelper: KeychainStoring {
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
var query = baseQuery(service: service, key: key)
|
||||
query[kSecReturnData as String] = true
|
||||
@ -81,6 +98,11 @@ actor KeychainHelper: KeychainStoring {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let query = baseQuery(service: service, key: key)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
@ -96,6 +118,12 @@ actor KeychainHelper: KeychainStoring {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
var query = baseQuery(service: service, key: key)
|
||||
query[kSecReturnData as String] = false
|
||||
@ -117,6 +145,9 @@ actor KeychainHelper: KeychainStoring {
|
||||
}
|
||||
|
||||
/// Deletes all items for a given service.
|
||||
///
|
||||
/// - Parameter service: Keychain service identifier.
|
||||
/// - Throws: ``StorageError/keychainError(_:)``.
|
||||
public func deleteAll(service: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
|
||||
@ -2,23 +2,32 @@ import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Actor that handles WatchConnectivity sync operations.
|
||||
///
|
||||
/// Manages data synchronization between iPhone and Apple Watch.
|
||||
actor SyncHelper {
|
||||
|
||||
/// Shared sync helper instance.
|
||||
public static let shared = SyncHelper()
|
||||
|
||||
private var configuration: SyncConfiguration
|
||||
|
||||
/// Creates a helper with a specific configuration.
|
||||
///
|
||||
/// - Parameter configuration: Sync configuration to apply.
|
||||
internal init(configuration: SyncConfiguration = .default) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Updates the sync configuration.
|
||||
///
|
||||
/// - Parameter configuration: New sync configuration.
|
||||
public func updateConfiguration(_ configuration: SyncConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Exposes the current max auto-sync size for filtering outbound payloads.
|
||||
///
|
||||
/// - Returns: The maximum size in bytes for automatic sync.
|
||||
public func maxAutoSyncSize() -> Int {
|
||||
configuration.maxAutoSyncSize
|
||||
}
|
||||
@ -68,7 +77,8 @@ actor SyncHelper {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
guard WCSession.isSupported() else { return false }
|
||||
|
||||
@ -88,6 +98,7 @@ actor SyncHelper {
|
||||
}
|
||||
|
||||
/// Gets the current application context.
|
||||
///
|
||||
/// - Returns: The current application context dictionary.
|
||||
public func currentContext() -> [String: Any] {
|
||||
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 {
|
||||
/// Shared delegate proxy instance.
|
||||
static let shared = SessionDelegateProxy()
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
/// 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 {
|
||||
|
||||
/// Shared helper instance.
|
||||
public static let shared = UserDefaultsHelper()
|
||||
|
||||
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) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
@ -20,12 +20,24 @@ public struct AppVersionConditionalMigration<Value: Codable & Sendable>: Conditi
|
||||
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 {
|
||||
let isEligible = context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending
|
||||
guard isEligible else { return false }
|
||||
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 {
|
||||
try await fallbackMigration.migrate(using: router, context: context)
|
||||
}
|
||||
|
||||
@ -20,10 +20,20 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
||||
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 {
|
||||
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 {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
@ -41,6 +51,12 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
||||
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 {
|
||||
let startTime = Date()
|
||||
var sourceData: [AnyCodable] = []
|
||||
|
||||
@ -20,10 +20,20 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
||||
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 {
|
||||
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 {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
@ -35,6 +45,12 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
||||
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 {
|
||||
let startTime = Date()
|
||||
|
||||
|
||||
@ -13,6 +13,12 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
||||
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 {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
@ -24,6 +30,12 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
||||
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 {
|
||||
let startTime = Date()
|
||||
var errors: [MigrationError] = []
|
||||
|
||||
@ -13,6 +13,7 @@ public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
/// Decodes a value from a single-value container.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
@ -33,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 {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ public struct AnyStorageKey: Sendable {
|
||||
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>) {
|
||||
self.descriptor = .from(key)
|
||||
self.migration = key.migration
|
||||
@ -26,6 +28,8 @@ public struct AnyStorageKey: Sendable {
|
||||
}
|
||||
|
||||
/// Convenience factory for creating a type-erased key.
|
||||
///
|
||||
/// - Parameter key: The concrete key to erase.
|
||||
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
||||
AnyStorageKey(key)
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ public struct AnyStorageMigration: Sendable {
|
||||
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) {
|
||||
self.destinationDescriptor = .from(migration.destinationKey)
|
||||
self.shouldMigrateAction = { @Sendable router, context in
|
||||
@ -20,11 +22,21 @@ 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 {
|
||||
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 {
|
||||
try await migrateAction(router, context)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// Defines additional access control requirements for keychain items.
|
||||
///
|
||||
/// These flags can require user authentication before accessing the item.
|
||||
public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
||||
/// 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.
|
||||
case biometryCurrentSetOrDevicePasscode
|
||||
|
||||
/// Creates a SecAccessControl object with the specified accessibility.
|
||||
/// Creates a `SecAccessControl` object with the specified accessibility.
|
||||
/// - 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? {
|
||||
let accessibilityValue = accessibility.cfString
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// 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 {
|
||||
/// Item is only accessible while the device is unlocked.
|
||||
/// 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] {
|
||||
[
|
||||
.whenUnlocked,
|
||||
|
||||
@ -21,6 +21,7 @@ public enum MigrationError: Error, Sendable, Equatable {
|
||||
}
|
||||
|
||||
extension MigrationError: LocalizedError {
|
||||
/// Localized description for display or logging.
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .validationFailed(let message):
|
||||
|
||||
@ -12,6 +12,7 @@ public enum PlatformAvailability: Sendable {
|
||||
case phoneWithWatchSync
|
||||
}
|
||||
|
||||
/// Convenience helpers for platform checks.
|
||||
public extension PlatformAvailability {
|
||||
/// Returns `true` if the key should be available on the given platform.
|
||||
func isAvailable(on platform: Platform) -> Bool {
|
||||
|
||||
@ -29,6 +29,8 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
||||
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> {
|
||||
Serializer<Value>(
|
||||
encode: { try JSONEncoder().encode($0) },
|
||||
@ -38,6 +40,8 @@ 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> {
|
||||
Serializer<Value>(
|
||||
encode: { try PropertyListEncoder().encode($0) },
|
||||
@ -47,6 +51,12 @@ 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(
|
||||
encode: @escaping @Sendable (Value) throws -> Data,
|
||||
decode: @escaping @Sendable (Data) throws -> Value,
|
||||
@ -56,8 +66,11 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience serializers for raw 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> {
|
||||
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ public enum StorageError: Error, Equatable {
|
||||
/// Missing or empty key description.
|
||||
case missingDescription(String)
|
||||
|
||||
/// Compares two storage errors for equality.
|
||||
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.serializationFailed, .serializationFailed),
|
||||
|
||||
@ -48,6 +48,11 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
_ key: StorageKey<Value>,
|
||||
catalog: String? = nil
|
||||
@ -67,6 +72,9 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
StorageKeyDescriptor(
|
||||
name: self.name,
|
||||
|
||||
@ -5,5 +5,8 @@ public protocol AggregatingMigration: StorageMigration {
|
||||
/// The set of source keys used to build the destination value.
|
||||
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
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ public protocol StorageKeyCatalog: Sendable {
|
||||
/// Human-readable catalog name used in audit reports.
|
||||
var name: String { get }
|
||||
/// All keys owned by this catalog.
|
||||
///
|
||||
/// Use type-erased ``AnyStorageKey`` to allow heterogeneous value types.
|
||||
var allKeys: [AnyStorageKey] { get }
|
||||
}
|
||||
|
||||
|
||||
@ -9,12 +9,23 @@ public protocol StorageMigration: Sendable {
|
||||
var destinationKey: StorageKey<Value> { get }
|
||||
|
||||
/// 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
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Default behavior for storage migrations.
|
||||
public extension StorageMigration {
|
||||
/// Default conditional behavior that checks platform availability and existing data.
|
||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
|
||||
@ -5,9 +5,18 @@ import Foundation
|
||||
/// Conforming types persist and retrieve values described by a ``StorageKey``.
|
||||
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
|
||||
/// 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
|
||||
/// Removes a value for the given key.
|
||||
///
|
||||
/// - Parameter key: The storage key to remove.
|
||||
func remove<Value>(_ key: StorageKey<Value>) async throws
|
||||
}
|
||||
|
||||
@ -8,5 +8,8 @@ public protocol TransformingMigration: StorageMigration {
|
||||
/// The source key to read from during migration.
|
||||
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
|
||||
}
|
||||
|
||||
@ -2,6 +2,12 @@ import Foundation
|
||||
|
||||
/// The main storage router that coordinates all storage operations.
|
||||
/// 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 {
|
||||
|
||||
/// Shared router instance for app-wide storage access.
|
||||
@ -38,25 +44,31 @@ public actor StorageRouter: StorageProviding {
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Updates the encryption configuration.
|
||||
/// > [!WARNING]
|
||||
/// > Changing these constants in an existing app will cause the app to look for the master key
|
||||
/// > under a new name. Previously encrypted data will be lost.
|
||||
///
|
||||
/// - Warning: Changing these constants in an existing app causes the app to look for
|
||||
/// the master key under a new name. Previously encrypted data may become inaccessible.
|
||||
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
||||
await encryption.updateConfiguration(configuration)
|
||||
await encryption.updateKeychainHelper(keychain)
|
||||
}
|
||||
|
||||
/// Updates the sync configuration.
|
||||
///
|
||||
/// - Parameter configuration: New sync settings, including maximum payload size.
|
||||
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
||||
await sync.updateConfiguration(configuration)
|
||||
}
|
||||
|
||||
/// Updates the file storage configuration.
|
||||
///
|
||||
/// - Parameter configuration: New file storage settings, including subdirectory scope.
|
||||
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
||||
await file.updateConfiguration(configuration)
|
||||
}
|
||||
|
||||
/// Updates the global storage configuration (defaults).
|
||||
///
|
||||
/// - Parameter configuration: Default identifiers for keychain and app group storage.
|
||||
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
|
||||
self.storageConfiguration = configuration
|
||||
}
|
||||
@ -64,6 +76,10 @@ public actor StorageRouter: StorageProviding {
|
||||
// MARK: - Key Material Providers
|
||||
|
||||
/// 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(
|
||||
_ provider: any KeyMaterialProviding,
|
||||
for source: KeyMaterialSource
|
||||
@ -77,6 +93,7 @@ public actor StorageRouter: StorageProviding {
|
||||
/// - Parameters:
|
||||
/// - catalog: The catalog type to register.
|
||||
/// - 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 {
|
||||
let entries = catalog.allKeys
|
||||
try validateDescription(entries)
|
||||
@ -120,6 +137,7 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
/// Triggers a proactive migration (sweep) for all registered storage keys.
|
||||
/// This "drains" any legacy data into the modern storage locations.
|
||||
/// - Throws: Migration or storage errors from individual keys.
|
||||
public func migrateAllRegisteredKeys() async throws {
|
||||
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
|
||||
for entry in registeredKeys.values {
|
||||
@ -129,6 +147,9 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
migrationHistory[key.name]
|
||||
}
|
||||
@ -557,6 +578,10 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
/// Attempts to sync any registered keys that already have stored values.
|
||||
/// 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 {
|
||||
let isAvailable = await sync.isSyncAvailable()
|
||||
guard isAvailable else {
|
||||
@ -595,6 +620,8 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
let isAvailable = await sync.isSyncAvailable()
|
||||
guard isAvailable else {
|
||||
|
||||
@ -2,8 +2,10 @@ import Foundation
|
||||
|
||||
/// Internal logging utility for the LocalData package.
|
||||
enum Logger {
|
||||
/// Enables or disables logging output.
|
||||
static var isLoggingEnabled = true
|
||||
|
||||
/// Emits a debug-level log message.
|
||||
static func debug(_ message: String) {
|
||||
#if DEBUG
|
||||
if isLoggingEnabled {
|
||||
@ -12,6 +14,7 @@ enum Logger {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Emits an info-level log message.
|
||||
static func info(_ message: String) {
|
||||
#if DEBUG
|
||||
if isLoggingEnabled {
|
||||
@ -20,6 +23,11 @@ enum Logger {
|
||||
#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) {
|
||||
#if DEBUG
|
||||
var logMessage = " {LOCAL_DATA} ❌ \(message)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user