Update Configuration, Models, Services + docs

Summary:
- Sources: Configuration, Models, Services
- Docs: README
- Added symbols: struct FileStorageConfiguration, struct StorageConfiguration, func updateConfiguration, func updateFileStorageConfiguration, func updateStorageConfiguration, func resolveService (+1 more)

Stats:
- 9 files changed, 144 insertions(+), 24 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-14 13:50:29 -06:00
parent bc5f1254b8
commit 9cbc561101
9 changed files with 144 additions and 24 deletions

View File

@ -48,6 +48,7 @@ These helpers are internal implementation details used by `StorageRouter`. They
- **StorageKeyDescriptor** - Audit snapshot of a keys storage metadata - **StorageKeyDescriptor** - Audit snapshot of a keys 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 {

View File

@ -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()
}

View 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()
}

View File

@ -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)
} }

View File

@ -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
} }
} }

View File

@ -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)))"
} }
} }

View File

@ -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
}
} }