LocalData/Sources/LocalData/Services/StorageRouter.swift
Matt Bruce 4228fbc849 Update Models, Services + docs
Summary:
- Sources: Models, Services
- Docs: Proposal, README
- Added symbols: extension StorageKeys, struct UserTokenKey, typealias Value, enum KeychainAccessControl, enum KeychainAccessibility, actor EncryptionHelper (+38 more)
- Removed symbols: enum KeychainAccessControl, enum KeychainAccessibility, enum EncryptionConstants, func serialize, func deserialize, func applySecurity (+17 more)

Stats:
- 10 files changed, 1089 insertions(+), 348 deletions(-)
2026-01-18 14:53:25 -06:00

214 lines
7.3 KiB
Swift

import Foundation
#if os(iOS) || os(watchOS)
import WatchConnectivity
#endif
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private init() {}
// MARK: - StorageProviding Implementation
/// Stores a value for the given key.
/// - Parameters:
/// - value: The value to store.
/// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy.
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer)
let securedData = try await applySecurity(data, for: key, isEncrypt: true)
try await store(securedData, for: key)
try await handleSync(key, data: securedData)
}
/// Retrieves a value for the given key.
/// - Parameter key: The storage key to retrieve.
/// - Returns: The stored value.
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
try validatePlatformAvailability(for: key)
guard let securedData = try await retrieve(for: key) else {
throw StorageError.notFound
}
let data = try await applySecurity(securedData, for: key, isEncrypt: false)
return try deserialize(data, with: key.serializer)
}
/// Removes the value for the given key.
/// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails.
public func remove<Key: StorageKey>(_ key: Key) async throws {
try validatePlatformAvailability(for: key)
try await delete(for: key)
}
/// Checks if a value exists for the given key.
/// - Parameter key: The storage key to check.
/// - Returns: True if a value exists.
public func exists<Key: StorageKey>(_ key: Key) async throws -> Bool {
try validatePlatformAvailability(for: key)
switch key.domain {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
case .keychain(let service):
return try await KeychainHelper.shared.exists(service: service, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
}
}
// MARK: - Platform Validation
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS)
if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
}
#elseif os(iOS)
if key.availability == .watchOnly {
throw StorageError.watchOnlyKeyAccessedOnPhone(key.name)
}
#endif
}
// MARK: - Serialization
private func serialize<Value: Codable & Sendable>(
_ value: Value,
with serializer: Serializer<Value>
) throws -> Data {
do {
return try serializer.encode(value)
} catch {
throw StorageError.serializationFailed
}
}
private func deserialize<Value: Codable & Sendable>(
_ data: Data,
with serializer: Serializer<Value>
) throws -> Value {
do {
return try serializer.decode(data)
} catch {
throw StorageError.deserializationFailed
}
}
// MARK: - Security
private func applySecurity(
_ data: Data,
for key: any StorageKey,
isEncrypt: Bool
) async throws -> Data {
switch key.security {
case .none:
return data
case .encrypted(let encryptionPolicy):
if isEncrypt {
return try await EncryptionHelper.shared.encrypt(
data,
keyName: key.name,
policy: encryptionPolicy
)
} else {
return try await EncryptionHelper.shared.decrypt(
data,
keyName: key.name,
policy: encryptionPolicy
)
}
case .keychain:
// Keychain security is handled in store/retrieve
return data
}
}
// MARK: - Storage Operations
private func store(_ data: Data, for key: any StorageKey) async throws {
switch key.domain {
case .userDefaults(let suite):
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
case .keychain(let service):
guard case let .keychain(accessibility, accessControl) = key.security else {
throw StorageError.securityApplicationFailed
}
try await KeychainHelper.shared.set(
data,
service: service,
key: key.name,
accessibility: accessibility,
accessControl: accessControl
)
case .fileSystem(let directory):
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
useCompleteFileProtection: false
)
case .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
useCompleteFileProtection: true
)
}
}
private func retrieve(for key: any StorageKey) async throws -> Data? {
switch key.domain {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite)
case .keychain(let service):
return try await KeychainHelper.shared.get(service: service, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await FileStorageHelper.shared.read(from: directory, fileName: key.name)
}
}
private func delete(for key: any StorageKey) async throws {
switch key.domain {
case .userDefaults(let suite):
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
case .keychain(let service):
try await KeychainHelper.shared.delete(service: service, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name)
}
}
// MARK: - Sync
private func handleSync(_ key: any StorageKey, data: Data) async throws {
try await SyncHelper.shared.syncIfNeeded(
data: data,
keyName: key.name,
availability: key.availability,
syncPolicy: key.syncPolicy
)
}
}