Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
ab95353119
commit
c4147f5f38
31
README.md
31
README.md
@ -131,7 +131,36 @@ let token = try await StorageRouter.shared.get(key)
|
|||||||
try await StorageRouter.shared.remove(key)
|
try await StorageRouter.shared.remove(key)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Storage Domains
|
### Complex Codable Support
|
||||||
|
LocalData handles complex `Codable` types automatically. You are not limited to simple strings or integers.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct UserProfile: Codable {
|
||||||
|
let id: UUID
|
||||||
|
let name: String
|
||||||
|
let settings: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StorageKeys {
|
||||||
|
struct ProfileKey: StorageKey {
|
||||||
|
typealias Value = UserProfile // Library handles serialization
|
||||||
|
let name = "user_profile"
|
||||||
|
let domain: StorageDomain = .fileSystem(directory: .documents)
|
||||||
|
// ... other properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## Storage Design Philosophy
|
||||||
|
|
||||||
|
This app intentionally uses a **Type-Safe Storage Design**. Unlike standard iOS development which uses string keys (e.g., `UserDefaults.standard.string(forKey: "user_name")`), this library requires you to define a `StorageKey` type.
|
||||||
|
|
||||||
|
### Why types instead of strings?
|
||||||
|
1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`.
|
||||||
|
2. **Codable Support**: Keys define their own value types. You can store complex `Codable` structs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically.
|
||||||
|
3. **Visibility**: All data your app stores is discoverable in the `StorageKeys/` folder. It serves as a manifest of your app's persistence layer.
|
||||||
|
4. **Migration**: You can move a piece of data from `UserDefaults` to `EncryptedFileSystem` just by changing the `domain` in the Key definition. No UI code needs to change.
|
||||||
|
|
||||||
|
## Storage Key Examples
|
||||||
|
|
||||||
| Domain | Use Case |
|
| Domain | Use Case |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
|||||||
@ -11,4 +11,11 @@ public protocol StorageKey: Sendable, CustomStringConvertible {
|
|||||||
|
|
||||||
var availability: PlatformAvailability { get }
|
var availability: PlatformAvailability { get }
|
||||||
var syncPolicy: SyncPolicy { get }
|
var syncPolicy: SyncPolicy { get }
|
||||||
|
|
||||||
|
/// Optional list of legacy keys to migrate data from if this key is empty.
|
||||||
|
var migrationSources: [AnyStorageKey] { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StorageKey {
|
||||||
|
public var migrationSources: [AnyStorageKey] { [] }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
|
|
||||||
let data = try serialize(value, with: key.serializer)
|
let data = try serialize(value, with: key.serializer)
|
||||||
let securedData = try await applySecurity(data, for: key, isEncrypt: true)
|
let securedData = try await applySecurity(data, for: .from(key), isEncrypt: true)
|
||||||
|
|
||||||
try await store(securedData, for: key)
|
try await store(securedData, for: key)
|
||||||
try await handleSync(key, data: securedData)
|
try await handleSync(key, data: securedData)
|
||||||
@ -88,24 +88,51 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
|
|
||||||
guard let securedData = try await retrieve(for: key) else {
|
// 1. Try primary location
|
||||||
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
|
if let securedData = try await retrieve(for: .from(key)) {
|
||||||
throw StorageError.notFound
|
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
|
||||||
}
|
|
||||||
|
|
||||||
let data = try await applySecurity(securedData, for: key, isEncrypt: false)
|
|
||||||
let result = try deserialize(data, with: key.serializer)
|
let result = try deserialize(data, with: key.serializer)
|
||||||
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
|
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Try migration sources
|
||||||
|
for source in key.migrationSources {
|
||||||
|
let descriptor = source.descriptor
|
||||||
|
Logger.debug("!!! [STORAGE] MIGRATION: Checking source '\(descriptor.name)' for key '\(key.name)'")
|
||||||
|
|
||||||
|
if let securedOldData = try await retrieve(for: descriptor) {
|
||||||
|
Logger.info("!!! [STORAGE] MIGRATION: Found data for '\(key.name)' at legacy source '\(descriptor.name)'. Migrating...")
|
||||||
|
|
||||||
|
// Unsecure using OLD key's policy
|
||||||
|
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
|
||||||
|
|
||||||
|
// Decode using NEW key's serializer (assuming types are compatible)
|
||||||
|
let value = try deserialize(oldData, with: key.serializer)
|
||||||
|
|
||||||
|
// Store in NEW location with NEW security
|
||||||
|
// This will also handle sync and catalog validation via recursive call to public set
|
||||||
|
try await self.set(value, for: key)
|
||||||
|
|
||||||
|
// Delete OLD data
|
||||||
|
try await delete(for: descriptor)
|
||||||
|
|
||||||
|
Logger.info("!!! [STORAGE] MIGRATION: Successfully migrated '\(key.name)' from '\(descriptor.name)'")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
|
||||||
|
throw StorageError.notFound
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes the value for the given key.
|
/// Removes the value for the given key.
|
||||||
/// - Parameter key: The storage key to remove.
|
/// - Parameter key: The storage key to remove.
|
||||||
/// - Throws: Domain-specific errors if removal fails.
|
/// - Throws: Domain-specific errors if removal fails.
|
||||||
public func remove<Key: StorageKey>(_ key: Key) async throws {
|
public func remove<Key: StorageKey>(_ key: Key) async throws {
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
try await delete(for: key)
|
try await delete(for: .from(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a value exists for the given key.
|
/// Checks if a value exists for the given key.
|
||||||
@ -216,10 +243,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
private func applySecurity(
|
private func applySecurity(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
for key: any StorageKey,
|
for descriptor: StorageKeyDescriptor,
|
||||||
isEncrypt: Bool
|
isEncrypt: Bool
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
switch key.security {
|
switch descriptor.security {
|
||||||
case .none:
|
case .none:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@ -227,13 +254,13 @@ public actor StorageRouter: StorageProviding {
|
|||||||
if isEncrypt {
|
if isEncrypt {
|
||||||
return try await EncryptionHelper.shared.encrypt(
|
return try await EncryptionHelper.shared.encrypt(
|
||||||
data,
|
data,
|
||||||
keyName: key.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return try await EncryptionHelper.shared.decrypt(
|
return try await EncryptionHelper.shared.decrypt(
|
||||||
data,
|
data,
|
||||||
keyName: key.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -247,23 +274,24 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Storage Operations
|
// MARK: - Storage Operations
|
||||||
|
|
||||||
private func store(_ data: Data, for key: any StorageKey) async throws {
|
private func store(_ data: Data, for key: any StorageKey) async throws {
|
||||||
switch key.domain {
|
let descriptor = StorageKeyDescriptor.from(key)
|
||||||
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
|
||||||
|
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await UserDefaultsHelper.shared.set(data, forKey: key.name, appGroupIdentifier: resolvedId)
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
guard case let .keychain(accessibility, accessControl) = key.security else {
|
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
||||||
throw StorageError.securityApplicationFailed
|
throw StorageError.securityApplicationFailed
|
||||||
}
|
}
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await KeychainHelper.shared.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: resolvedService,
|
service: resolvedService,
|
||||||
key: key.name,
|
key: descriptor.name,
|
||||||
accessibility: accessibility,
|
accessibility: accessibility,
|
||||||
accessControl: accessControl
|
accessControl: accessControl
|
||||||
)
|
)
|
||||||
@ -272,7 +300,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try await FileStorageHelper.shared.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: key.name,
|
fileName: descriptor.name,
|
||||||
useCompleteFileProtection: false
|
useCompleteFileProtection: false
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -280,7 +308,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try await FileStorageHelper.shared.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: key.name,
|
fileName: descriptor.name,
|
||||||
useCompleteFileProtection: true
|
useCompleteFileProtection: true
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -289,56 +317,56 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try await FileStorageHelper.shared.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: key.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId,
|
appGroupIdentifier: resolvedId,
|
||||||
useCompleteFileProtection: false
|
useCompleteFileProtection: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func retrieve(for key: any StorageKey) async throws -> Data? {
|
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||||
switch key.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite)
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: resolvedId)
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await KeychainHelper.shared.get(service: resolvedService, key: key.name)
|
return try await KeychainHelper.shared.get(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return try await FileStorageHelper.shared.read(from: directory, fileName: key.name)
|
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await FileStorageHelper.shared.read(
|
return try await FileStorageHelper.shared.read(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: key.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func delete(for key: any StorageKey) async throws {
|
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||||
switch key.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: resolvedId)
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await KeychainHelper.shared.delete(service: resolvedService, key: key.name)
|
try await KeychainHelper.shared.delete(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name)
|
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await FileStorageHelper.shared.delete(
|
try await FileStorageHelper.shared.delete(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: key.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,14 @@ enum Logger {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func info(_ message: String) {
|
||||||
|
#if DEBUG
|
||||||
|
if isLoggingEnabled {
|
||||||
|
print(" {LOCAL_DATA} 📢 \(message)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
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)"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user