Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-14 11:54:22 -06:00
parent 04937f5357
commit 6c3712e883
6 changed files with 209 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -10,6 +10,21 @@ actor FileStorageHelper {
// 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:
/// - data: The data to write.
@ -64,6 +79,18 @@ actor FileStorageHelper {
}
}
/// 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:
/// - directory: The base directory.
@ -86,6 +113,18 @@ actor FileStorageHelper {
}
}
/// 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:
/// - directory: The base directory.
@ -99,6 +138,22 @@ actor FileStorageHelper {
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.
/// - Returns: An array of file names.
@ -117,6 +172,13 @@ actor FileStorageHelper {
}
}
/// 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:
/// - directory: The base directory.
@ -141,6 +203,18 @@ actor FileStorageHelper {
}
}
/// 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
private func ensureDirectoryExists(at url: URL) throws {
@ -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)
}
}
}

View File

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

View File

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