diff --git a/README.md b/README.md index 76e643b..fb5a88a 100644 --- a/README.md +++ b/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 - **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length) - **SyncConfiguration** - Global sync settings (Max automatic sync size) +- **FileStorageConfiguration** - Global file settings (Custom folder names) - **AnyStorageKey** - Type-erased storage key for catalogs - **AnyCodable** - Type-erased Codable for mixed-type payloads @@ -99,9 +100,22 @@ try await StorageRouter.shared.remove(key) | `userDefaults` | Preferences, small settings | | `appGroupUserDefaults` | Shared settings across extensions via App Groups | | `keychain` | Credentials, tokens, sensitive data | -| `fileSystem` | Documents, cached data, large files | -| `encryptedFileSystem` | Sensitive files with encryption policies | -| `appGroupFileSystem` | Shared files across extensions via App Groups | +| `fileSystem(directory:)` | Local storage in Documents or Caches | +| `encryptedFileSystem(directory:)` | Sensitive files with encryption policies | +| `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 @@ -162,6 +176,22 @@ let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit 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 struct RemoteKeyProvider: KeyMaterialProviding { func keyMaterial(for keyName: String) async throws -> Data { diff --git a/Sources/LocalData/Models/EncryptionConfiguration.swift b/Sources/LocalData/Configuration/EncryptionConfiguration.swift similarity index 100% rename from Sources/LocalData/Models/EncryptionConfiguration.swift rename to Sources/LocalData/Configuration/EncryptionConfiguration.swift diff --git a/Sources/LocalData/Configuration/FileStorageConfiguration.swift b/Sources/LocalData/Configuration/FileStorageConfiguration.swift new file mode 100644 index 0000000..f25407b --- /dev/null +++ b/Sources/LocalData/Configuration/FileStorageConfiguration.swift @@ -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() +} diff --git a/Sources/LocalData/Configuration/StorageConfiguration.swift b/Sources/LocalData/Configuration/StorageConfiguration.swift new file mode 100644 index 0000000..8cb1d24 --- /dev/null +++ b/Sources/LocalData/Configuration/StorageConfiguration.swift @@ -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() +} diff --git a/Sources/LocalData/Models/SyncConfiguration.swift b/Sources/LocalData/Configuration/SyncConfiguration.swift similarity index 100% rename from Sources/LocalData/Models/SyncConfiguration.swift rename to Sources/LocalData/Configuration/SyncConfiguration.swift diff --git a/Sources/LocalData/Models/StorageDomain.swift b/Sources/LocalData/Models/StorageDomain.swift index c2bb0c5..fab42d0 100644 --- a/Sources/LocalData/Models/StorageDomain.swift +++ b/Sources/LocalData/Models/StorageDomain.swift @@ -2,9 +2,9 @@ import Foundation public enum StorageDomain: Sendable { case userDefaults(suite: String?) - case appGroupUserDefaults(identifier: String) - case keychain(service: String) + case appGroupUserDefaults(identifier: String?) + case keychain(service: String?) case fileSystem(directory: FileDirectory) case encryptedFileSystem(directory: FileDirectory) - case appGroupFileSystem(identifier: String, directory: FileDirectory) + case appGroupFileSystem(identifier: String?, directory: FileDirectory) } diff --git a/Sources/LocalData/Services/FileStorageHelper.swift b/Sources/LocalData/Services/FileStorageHelper.swift index dcafba6..3f89efe 100644 --- a/Sources/LocalData/Services/FileStorageHelper.swift +++ b/Sources/LocalData/Services/FileStorageHelper.swift @@ -6,7 +6,16 @@ actor 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 @@ -305,14 +314,21 @@ actor FileStorageHelper { } private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL { + let base: URL switch directory { case .documents: - return baseURL.appending(path: "Documents") + base = baseURL.appending(path: "Documents") case .caches: - return baseURL.appending(path: "Library/Caches") + base = baseURL.appending(path: "Library/Caches") case .custom(let url): let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) return baseURL.appending(path: relativePath) } + + if let subDirectory = configuration.subDirectory { + return base.appending(path: subDirectory) + } + + return base } } diff --git a/Sources/LocalData/Services/StorageAuditReport.swift b/Sources/LocalData/Services/StorageAuditReport.swift index c5ec85a..050f144 100644 --- a/Sources/LocalData/Services/StorageAuditReport.swift +++ b/Sources/LocalData/Services/StorageAuditReport.swift @@ -31,15 +31,15 @@ public struct StorageAuditReport: Sendable { case .userDefaults(let suite): return "userDefaults(\(suite ?? "standard"))" case .appGroupUserDefaults(let identifier): - return "appGroupUserDefaults(\(identifier))" + return "appGroupUserDefaults(\(identifier ?? "default"))" case .keychain(let service): - return "keychain(\(service))" + return "keychain(\(service ?? "default"))" case .fileSystem(let directory): return "fileSystem(\(string(for: directory)))" case .encryptedFileSystem(let directory): return "encryptedFileSystem(\(string(for: directory)))" case .appGroupFileSystem(let identifier, let directory): - return "appGroupFileSystem(\(identifier), \(string(for: directory)))" + return "appGroupFileSystem(\(identifier ?? "default"), \(string(for: directory)))" } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 4cbae3a..798fb00 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -10,6 +10,7 @@ public actor StorageRouter: StorageProviding { public static let shared = StorageRouter() private var registeredKeyNames: Set = [] + private var storageConfiguration: StorageConfiguration = .default private init() {} @@ -28,6 +29,16 @@ public actor StorageRouter: StorageProviding { 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 /// Registers a key material provider for external encryption policies. @@ -101,16 +112,19 @@ public actor StorageRouter: StorageProviding { case .userDefaults(let suite): return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite) 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): - 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): return await FileStorageHelper.shared.exists(in: directory, fileName: key.name) case .appGroupFileSystem(let identifier, let directory): + let resolvedId = try resolveIdentifier(identifier) return await FileStorageHelper.shared.exists( in: directory, 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) 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): guard case let .keychain(accessibility, accessControl) = key.security else { throw StorageError.securityApplicationFailed } + let resolvedService = try resolveService(service) try await KeychainHelper.shared.set( data, - service: service, + service: resolvedService, key: key.name, accessibility: accessibility, accessControl: accessControl @@ -262,11 +278,12 @@ public actor StorageRouter: StorageProviding { ) case .appGroupFileSystem(let identifier, let directory): + let resolvedId = try resolveIdentifier(identifier) try await FileStorageHelper.shared.write( data, to: directory, fileName: key.name, - appGroupIdentifier: identifier, + appGroupIdentifier: resolvedId, useCompleteFileProtection: false ) } @@ -277,18 +294,21 @@ public actor StorageRouter: StorageProviding { case .userDefaults(let suite): return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite) 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): - 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): return try await FileStorageHelper.shared.read(from: directory, fileName: key.name) case .appGroupFileSystem(let identifier, let directory): + let resolvedId = try resolveIdentifier(identifier) return try await FileStorageHelper.shared.read( from: directory, fileName: key.name, - appGroupIdentifier: identifier + appGroupIdentifier: resolvedId ) } } @@ -298,18 +318,21 @@ public actor StorageRouter: StorageProviding { case .userDefaults(let suite): try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite) 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): - 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): try await FileStorageHelper.shared.delete(from: directory, fileName: key.name) case .appGroupFileSystem(let identifier, let directory): + let resolvedId = try resolveIdentifier(identifier) try await FileStorageHelper.shared.delete( from: directory, fileName: key.name, - appGroupIdentifier: identifier + appGroupIdentifier: resolvedId ) } } @@ -324,4 +347,20 @@ public actor StorageRouter: StorageProviding { 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 + } }