diff --git a/README.md b/README.md index f4f3959..66fe559 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ These helpers are internal implementation details used by `StorageRouter`. They - **SyncHelper** - Manages WatchConnectivity sync. ### Models -- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem +- **StorageDomain** - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem - **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305) - **Serializer** - JSON, plist, Data, or custom - **KeyMaterialSource** - Identifier for external key material providers @@ -95,9 +95,17 @@ try await StorageRouter.shared.remove(key) | Domain | Use Case | |--------|----------| | `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 | + +## App Group Support + +App Group storage is explicit via `StorageDomain.appGroupUserDefaults` and `StorageDomain.appGroupFileSystem`. These require a valid App Group identifier and the corresponding entitlement on every target that needs access. If the identifier is invalid or missing, LocalData throws `StorageError.invalidAppGroupIdentifier`. + +Use standard `userDefaults` or `fileSystem` for data that should remain scoped to a single target, even when App Groups are configured. ## Security Options diff --git a/Sources/LocalData/Models/StorageDomain.swift b/Sources/LocalData/Models/StorageDomain.swift index eab3e04..c2bb0c5 100644 --- a/Sources/LocalData/Models/StorageDomain.swift +++ b/Sources/LocalData/Models/StorageDomain.swift @@ -2,7 +2,9 @@ import Foundation public enum StorageDomain: Sendable { case userDefaults(suite: String?) + case appGroupUserDefaults(identifier: String) case keychain(service: String) case fileSystem(directory: FileDirectory) case encryptedFileSystem(directory: FileDirectory) + case appGroupFileSystem(identifier: String, directory: FileDirectory) } diff --git a/Sources/LocalData/Models/StorageError.swift b/Sources/LocalData/Models/StorageError.swift index 192b6a5..a14695e 100644 --- a/Sources/LocalData/Models/StorageError.swift +++ b/Sources/LocalData/Models/StorageError.swift @@ -8,6 +8,7 @@ public enum StorageError: Error { case phoneOnlyKeyAccessedOnWatch(String) case watchOnlyKeyAccessedOnPhone(String) case invalidUserDefaultsSuite(String) + case invalidAppGroupIdentifier(String) case dataTooLargeForSync case notFound case unregisteredKey(String) diff --git a/Sources/LocalData/Services/FileStorageHelper.swift b/Sources/LocalData/Services/FileStorageHelper.swift index 219a332..dcafba6 100644 --- a/Sources/LocalData/Services/FileStorageHelper.swift +++ b/Sources/LocalData/Services/FileStorageHelper.swift @@ -9,6 +9,21 @@ actor FileStorageHelper { private init() {} // MARK: - Public Interface + + /// Writes data to an App Group container. + public func write( + _ data: Data, + to directory: FileDirectory, + fileName: String, + appGroupIdentifier: String, + useCompleteFileProtection: Bool = false + ) throws { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + let url = directoryURL.appendingPathComponent(fileName) + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection) + } /// Writes data to a file. /// - Parameters: @@ -63,6 +78,18 @@ actor FileStorageHelper { throw StorageError.fileError(error) } } + + /// Reads data from an App Group container. + public func read( + from directory: FileDirectory, + fileName: String, + appGroupIdentifier: String + ) throws -> Data? { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + let url = directoryURL.appendingPathComponent(fileName) + return try read(from: url) + } /// Deletes a file. /// - Parameters: @@ -85,6 +112,18 @@ actor FileStorageHelper { throw StorageError.fileError(error) } } + + /// Deletes a file from an App Group container. + public func delete( + from directory: FileDirectory, + fileName: String, + appGroupIdentifier: String + ) throws { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + let url = directoryURL.appendingPathComponent(fileName) + try delete(file: url) + } /// Checks if a file exists. /// - Parameters: @@ -98,6 +137,22 @@ actor FileStorageHelper { let url = directory.url().appendingPathComponent(fileName) return FileManager.default.fileExists(atPath: url.path) } + + /// Checks if a file exists in an App Group container. + public func exists( + in directory: FileDirectory, + fileName: String, + appGroupIdentifier: String + ) -> Bool { + do { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + let url = directoryURL.appendingPathComponent(fileName) + return FileManager.default.fileExists(atPath: url.path) + } catch { + return false + } + } /// Lists all files in a directory. /// - Parameter directory: The directory to list. @@ -116,6 +171,13 @@ actor FileStorageHelper { throw StorageError.fileError(error) } } + + /// Lists all files in an App Group container directory. + public func list(in directory: FileDirectory, appGroupIdentifier: String) throws -> [String] { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + return try list(in: directoryURL) + } /// Gets the size of a file in bytes. /// - Parameters: @@ -140,6 +202,18 @@ actor FileStorageHelper { throw StorageError.fileError(error) } } + + /// Gets the size of a file in an App Group container. + public func size( + of directory: FileDirectory, + fileName: String, + appGroupIdentifier: String + ) throws -> Int64? { + let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) + let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) + let url = directoryURL.appendingPathComponent(fileName) + return try size(of: url) + } // MARK: - Private Helpers @@ -158,4 +232,87 @@ actor FileStorageHelper { throw StorageError.fileError(error) } } + + private func write(_ data: Data, to url: URL, useCompleteFileProtection: Bool) throws { + var options: Data.WritingOptions = [.atomic] + if useCompleteFileProtection { + options.insert(.completeFileProtection) + } + + do { + try data.write(to: url, options: options) + } catch { + throw StorageError.fileError(error) + } + } + + private func read(from url: URL) throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.fileError(error) + } + } + + private func delete(file url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { + return + } + + do { + try FileManager.default.removeItem(at: url) + } catch { + throw StorageError.fileError(error) + } + } + + private func list(in url: URL) throws -> [String] { + guard FileManager.default.fileExists(atPath: url.path) else { + return [] + } + + do { + return try FileManager.default.contentsOfDirectory(atPath: url.path) + } catch { + throw StorageError.fileError(error) + } + } + + private func size(of url: URL) throws -> Int64? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? Int64 + } catch { + throw StorageError.fileError(error) + } + } + + private func appGroupContainerURL(identifier: String) throws -> URL { + guard let url = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: identifier + ) else { + throw StorageError.invalidAppGroupIdentifier(identifier) + } + return url + } + + private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL { + switch directory { + case .documents: + return baseURL.appending(path: "Documents") + case .caches: + return baseURL.appending(path: "Library/Caches") + case .custom(let url): + let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return baseURL.appending(path: relativePath) + } + } } diff --git a/Sources/LocalData/Services/StorageAuditReport.swift b/Sources/LocalData/Services/StorageAuditReport.swift index bb303dc..c5ec85a 100644 --- a/Sources/LocalData/Services/StorageAuditReport.swift +++ b/Sources/LocalData/Services/StorageAuditReport.swift @@ -30,12 +30,16 @@ public struct StorageAuditReport: Sendable { switch domain { case .userDefaults(let suite): return "userDefaults(\(suite ?? "standard"))" + case .appGroupUserDefaults(let identifier): + return "appGroupUserDefaults(\(identifier))" case .keychain(let service): return "keychain(\(service))" 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)))" } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 67b8697..fe40baa 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -85,10 +85,18 @@ public actor StorageRouter: StorageProviding { switch key.domain { 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, suite: identifier) 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) + case .appGroupFileSystem(let identifier, let directory): + return await FileStorageHelper.shared.exists( + in: directory, + fileName: key.name, + appGroupIdentifier: identifier + ) } } @@ -207,6 +215,9 @@ public actor StorageRouter: StorageProviding { case .userDefaults(let suite): try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite) + case .appGroupUserDefaults(let identifier): + try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: identifier) + case .keychain(let service): guard case let .keychain(accessibility, accessControl) = key.security else { throw StorageError.securityApplicationFailed @@ -234,6 +245,15 @@ public actor StorageRouter: StorageProviding { fileName: key.name, useCompleteFileProtection: true ) + + case .appGroupFileSystem(let identifier, let directory): + try await FileStorageHelper.shared.write( + data, + to: directory, + fileName: key.name, + appGroupIdentifier: identifier, + useCompleteFileProtection: false + ) } } @@ -241,12 +261,20 @@ public actor StorageRouter: StorageProviding { switch key.domain { 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, suite: identifier) 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) + case .appGroupFileSystem(let identifier, let directory): + return try await FileStorageHelper.shared.read( + from: directory, + fileName: key.name, + appGroupIdentifier: identifier + ) } } @@ -254,12 +282,20 @@ public actor StorageRouter: StorageProviding { switch key.domain { 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, suite: identifier) 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) + case .appGroupFileSystem(let identifier, let directory): + try await FileStorageHelper.shared.delete( + from: directory, + fileName: key.name, + appGroupIdentifier: identifier + ) } }