Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
8c76769ced
commit
dd61bedf14
36
README.md
36
README.md
@ -48,6 +48,7 @@ These helpers are internal implementation details used by `StorageRouter`. They
|
|||||||
- **StorageKeyDescriptor** - Audit snapshot of a key’s storage metadata
|
- **StorageKeyDescriptor** - Audit snapshot of a key’s storage metadata
|
||||||
- **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length)
|
- **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length)
|
||||||
- **SyncConfiguration** - Global sync settings (Max automatic sync size)
|
- **SyncConfiguration** - Global sync settings (Max automatic sync size)
|
||||||
|
- **FileStorageConfiguration** - Global file settings (Custom folder names)
|
||||||
- **AnyStorageKey** - Type-erased storage key for catalogs
|
- **AnyStorageKey** - Type-erased storage key for catalogs
|
||||||
- **AnyCodable** - Type-erased Codable for mixed-type payloads
|
- **AnyCodable** - Type-erased Codable for mixed-type payloads
|
||||||
|
|
||||||
@ -99,9 +100,22 @@ try await StorageRouter.shared.remove(key)
|
|||||||
| `userDefaults` | Preferences, small settings |
|
| `userDefaults` | Preferences, small settings |
|
||||||
| `appGroupUserDefaults` | Shared settings across extensions via App Groups |
|
| `appGroupUserDefaults` | Shared settings across extensions via App Groups |
|
||||||
| `keychain` | Credentials, tokens, sensitive data |
|
| `keychain` | Credentials, tokens, sensitive data |
|
||||||
| `fileSystem` | Documents, cached data, large files |
|
| `fileSystem(directory:)` | Local storage in Documents or Caches |
|
||||||
| `encryptedFileSystem` | Sensitive files with encryption policies |
|
| `encryptedFileSystem(directory:)` | Sensitive files with encryption policies |
|
||||||
| `appGroupFileSystem` | Shared files across extensions via App Groups |
|
| `appGroupFileSystem(id:directory:)` | Shared files across targets via App Groups |
|
||||||
|
|
||||||
|
### File Directories
|
||||||
|
|
||||||
|
The library supports two standard iOS locations via `FileDirectory`:
|
||||||
|
|
||||||
|
| Directory | Persistence | iCloud Backup | Recommended Use |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `.documents` | Permanent | Yes | User data, critical settings |
|
||||||
|
| `.caches` | Purgeable* | No | Temporary files, downloaded assets |
|
||||||
|
|
||||||
|
*\*iOS may delete files in `.caches` if the device runs low on storage.*
|
||||||
|
|
||||||
|
By configuring a `subDirectory` in `FileStorageConfiguration`, you ensure that the library's data is isolated within its own folder in both locations (e.g., `Documents/MyData/` and `Caches/MyData/`).
|
||||||
|
|
||||||
## App Group Support
|
## App Group Support
|
||||||
|
|
||||||
@ -162,6 +176,22 @@ let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit
|
|||||||
await StorageRouter.shared.updateSyncConfiguration(syncConfig)
|
await StorageRouter.shared.updateSyncConfiguration(syncConfig)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Global File Storage Configuration
|
||||||
|
|
||||||
|
You can scope all library files into a specific sub-directory (e.g., to avoid cluttering the root Documents folder):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let fileConfig = FileStorageConfiguration(subDirectory: "MyAppStorage")
|
||||||
|
await StorageRouter.shared.updateFileStorageConfiguration(fileConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
This will result in paths like:
|
||||||
|
- `.../Documents/MyAppStorage/` (Main Sandbox)
|
||||||
|
- `.../SharedContainer/Documents/MyAppStorage/` (App Group)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Changing the `subDirectory` in an existing app will cause the library to look in the new location. Existing files in the old location will not be automatically moved.
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
struct RemoteKeyProvider: KeyMaterialProviding {
|
struct RemoteKeyProvider: KeyMaterialProviding {
|
||||||
func keyMaterial(for keyName: String) async throws -> Data {
|
func keyMaterial(for keyName: String) async throws -> Data {
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for the FileStorageHelper.
|
||||||
|
public struct FileStorageConfiguration: Sendable {
|
||||||
|
/// An optional sub-directory to scope all library files within.
|
||||||
|
/// If provided, files will be stored in `.../Documents/{subDirectory}/` instead of `.../Documents/`.
|
||||||
|
public let subDirectory: String?
|
||||||
|
|
||||||
|
public init(subDirectory: String? = nil) {
|
||||||
|
self.subDirectory = subDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = FileStorageConfiguration()
|
||||||
|
}
|
||||||
21
Sources/LocalData/Configuration/StorageConfiguration.swift
Normal file
21
Sources/LocalData/Configuration/StorageConfiguration.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Global configuration for the storage engine.
|
||||||
|
/// Allows setting default identifiers for Keychain services and App Groups.
|
||||||
|
public struct StorageConfiguration: Sendable {
|
||||||
|
/// The default Keychain service to use if none is specified in a StorageKey.
|
||||||
|
public let defaultKeychainService: String?
|
||||||
|
|
||||||
|
/// The default App Group identifier to use if none is specified in a StorageKey.
|
||||||
|
public let defaultAppGroupIdentifier: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
defaultKeychainService: String? = nil,
|
||||||
|
defaultAppGroupIdentifier: String? = nil
|
||||||
|
) {
|
||||||
|
self.defaultKeychainService = defaultKeychainService
|
||||||
|
self.defaultAppGroupIdentifier = defaultAppGroupIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = StorageConfiguration()
|
||||||
|
}
|
||||||
@ -2,9 +2,9 @@ import Foundation
|
|||||||
|
|
||||||
public enum StorageDomain: Sendable {
|
public enum StorageDomain: Sendable {
|
||||||
case userDefaults(suite: String?)
|
case userDefaults(suite: String?)
|
||||||
case appGroupUserDefaults(identifier: String)
|
case appGroupUserDefaults(identifier: String?)
|
||||||
case keychain(service: String)
|
case keychain(service: String?)
|
||||||
case fileSystem(directory: FileDirectory)
|
case fileSystem(directory: FileDirectory)
|
||||||
case encryptedFileSystem(directory: FileDirectory)
|
case encryptedFileSystem(directory: FileDirectory)
|
||||||
case appGroupFileSystem(identifier: String, directory: FileDirectory)
|
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,16 @@ actor FileStorageHelper {
|
|||||||
|
|
||||||
public static let shared = FileStorageHelper()
|
public static let shared = FileStorageHelper()
|
||||||
|
|
||||||
private init() {}
|
private var configuration: FileStorageConfiguration
|
||||||
|
|
||||||
|
private init(configuration: FileStorageConfiguration = .default) {
|
||||||
|
self.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the file storage configuration.
|
||||||
|
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
|
||||||
|
self.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@ -305,14 +314,21 @@ actor FileStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL {
|
private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL {
|
||||||
|
let base: URL
|
||||||
switch directory {
|
switch directory {
|
||||||
case .documents:
|
case .documents:
|
||||||
return baseURL.appending(path: "Documents")
|
base = baseURL.appending(path: "Documents")
|
||||||
case .caches:
|
case .caches:
|
||||||
return baseURL.appending(path: "Library/Caches")
|
base = baseURL.appending(path: "Library/Caches")
|
||||||
case .custom(let url):
|
case .custom(let url):
|
||||||
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
return baseURL.appending(path: relativePath)
|
return baseURL.appending(path: relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let subDirectory = configuration.subDirectory {
|
||||||
|
return base.appending(path: subDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,15 +31,15 @@ public struct StorageAuditReport: Sendable {
|
|||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return "userDefaults(\(suite ?? "standard"))"
|
return "userDefaults(\(suite ?? "standard"))"
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
return "appGroupUserDefaults(\(identifier))"
|
return "appGroupUserDefaults(\(identifier ?? "default"))"
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
return "keychain(\(service))"
|
return "keychain(\(service ?? "default"))"
|
||||||
case .fileSystem(let directory):
|
case .fileSystem(let directory):
|
||||||
return "fileSystem(\(string(for: directory)))"
|
return "fileSystem(\(string(for: directory)))"
|
||||||
case .encryptedFileSystem(let directory):
|
case .encryptedFileSystem(let directory):
|
||||||
return "encryptedFileSystem(\(string(for: directory)))"
|
return "encryptedFileSystem(\(string(for: directory)))"
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
return "appGroupFileSystem(\(identifier), \(string(for: directory)))"
|
return "appGroupFileSystem(\(identifier ?? "default"), \(string(for: directory)))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
public static let shared = StorageRouter()
|
public static let shared = StorageRouter()
|
||||||
|
|
||||||
private var registeredKeyNames: Set<String> = []
|
private var registeredKeyNames: Set<String> = []
|
||||||
|
private var storageConfiguration: StorageConfiguration = .default
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@ -28,6 +29,16 @@ public actor StorageRouter: StorageProviding {
|
|||||||
await SyncHelper.shared.updateConfiguration(configuration)
|
await SyncHelper.shared.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the file storage configuration.
|
||||||
|
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
||||||
|
await FileStorageHelper.shared.updateConfiguration(configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the global storage configuration (defaults).
|
||||||
|
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
|
||||||
|
self.storageConfiguration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@ -101,16 +112,19 @@ public actor StorageRouter: StorageProviding {
|
|||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
return try await KeychainHelper.shared.exists(service: service, key: key.name)
|
let resolvedService = try resolveService(service)
|
||||||
|
return try await KeychainHelper.shared.exists(service: resolvedService, key: key.name)
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return await FileStorageHelper.shared.exists(
|
return await FileStorageHelper.shared.exists(
|
||||||
in: directory,
|
in: directory,
|
||||||
fileName: key.name,
|
fileName: key.name,
|
||||||
appGroupIdentifier: identifier
|
appGroupIdentifier: resolvedId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -231,15 +245,17 @@ public actor StorageRouter: StorageProviding {
|
|||||||
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
|
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
|
||||||
|
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
try await UserDefaultsHelper.shared.set(data, forKey: key.name, appGroupIdentifier: identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
|
try await UserDefaultsHelper.shared.set(data, forKey: key.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) = key.security else {
|
||||||
throw StorageError.securityApplicationFailed
|
throw StorageError.securityApplicationFailed
|
||||||
}
|
}
|
||||||
|
let resolvedService = try resolveService(service)
|
||||||
try await KeychainHelper.shared.set(
|
try await KeychainHelper.shared.set(
|
||||||
data,
|
data,
|
||||||
service: service,
|
service: resolvedService,
|
||||||
key: key.name,
|
key: key.name,
|
||||||
accessibility: accessibility,
|
accessibility: accessibility,
|
||||||
accessControl: accessControl
|
accessControl: accessControl
|
||||||
@ -262,11 +278,12 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await FileStorageHelper.shared.write(
|
try await FileStorageHelper.shared.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: key.name,
|
fileName: key.name,
|
||||||
appGroupIdentifier: identifier,
|
appGroupIdentifier: resolvedId,
|
||||||
useCompleteFileProtection: false
|
useCompleteFileProtection: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -277,18 +294,21 @@ public actor StorageRouter: StorageProviding {
|
|||||||
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: key.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
|
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
return try await KeychainHelper.shared.get(service: service, key: key.name)
|
let resolvedService = try resolveService(service)
|
||||||
|
return try await KeychainHelper.shared.get(service: resolvedService, key: key.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: key.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
|
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: key.name,
|
||||||
appGroupIdentifier: identifier
|
appGroupIdentifier: resolvedId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -298,18 +318,21 @@ public actor StorageRouter: StorageProviding {
|
|||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
|
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
|
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
try await KeychainHelper.shared.delete(service: service, key: key.name)
|
let resolvedService = try resolveService(service)
|
||||||
|
try await KeychainHelper.shared.delete(service: resolvedService, key: key.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: key.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await FileStorageHelper.shared.delete(
|
try await FileStorageHelper.shared.delete(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: key.name,
|
fileName: key.name,
|
||||||
appGroupIdentifier: identifier
|
appGroupIdentifier: resolvedId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -324,4 +347,20 @@ public actor StorageRouter: StorageProviding {
|
|||||||
syncPolicy: key.syncPolicy
|
syncPolicy: key.syncPolicy
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolution Helpers
|
||||||
|
|
||||||
|
private func resolveService(_ service: String?) throws -> String {
|
||||||
|
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
|
||||||
|
throw StorageError.keychainError(errSecBadReq) // Or a more specific error
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveIdentifier(_ identifier: String?) throws -> String {
|
||||||
|
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
|
||||||
|
throw StorageError.invalidAppGroupIdentifier("none")
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user