Update Models, Services + docs

Summary:
- Sources: Models, Services
- Docs: README
- Added symbols: func write, func read, func delete, func exists, func list, func size (+2 more)

Stats:
- 6 files changed, 209 insertions(+), 1 deletion(-)
This commit is contained in:
Matt Bruce 2026-01-14 11:54:22 -06:00
parent 28ab0bf475
commit 49f48e134d
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. - **SyncHelper** - Manages WatchConnectivity sync.
### Models ### Models
- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem - **StorageDomain** - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem
- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305) - **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
- **Serializer** - JSON, plist, Data, or custom - **Serializer** - JSON, plist, Data, or custom
- **KeyMaterialSource** - Identifier for external key material providers - **KeyMaterialSource** - Identifier for external key material providers
@ -95,9 +95,17 @@ try await StorageRouter.shared.remove(key)
| Domain | Use Case | | Domain | Use Case |
|--------|----------| |--------|----------|
| `userDefaults` | Preferences, small settings | | `userDefaults` | Preferences, small settings |
| `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` | Documents, cached data, large files |
| `encryptedFileSystem` | Sensitive files with encryption policies | | `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 ## Security Options

View File

@ -2,7 +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 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)
} }

View File

@ -8,6 +8,7 @@ public enum StorageError: Error {
case phoneOnlyKeyAccessedOnWatch(String) case phoneOnlyKeyAccessedOnWatch(String)
case watchOnlyKeyAccessedOnPhone(String) case watchOnlyKeyAccessedOnPhone(String)
case invalidUserDefaultsSuite(String) case invalidUserDefaultsSuite(String)
case invalidAppGroupIdentifier(String)
case dataTooLargeForSync case dataTooLargeForSync
case notFound case notFound
case unregisteredKey(String) case unregisteredKey(String)

View File

@ -9,6 +9,21 @@ actor FileStorageHelper {
private init() {} private init() {}
// MARK: - Public Interface // 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. /// Writes data to a file.
/// - Parameters: /// - Parameters:
@ -63,6 +78,18 @@ actor FileStorageHelper {
throw StorageError.fileError(error) 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. /// Deletes a file.
/// - Parameters: /// - Parameters:
@ -85,6 +112,18 @@ actor FileStorageHelper {
throw StorageError.fileError(error) 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. /// Checks if a file exists.
/// - Parameters: /// - Parameters:
@ -98,6 +137,22 @@ actor FileStorageHelper {
let url = directory.url().appendingPathComponent(fileName) let url = directory.url().appendingPathComponent(fileName)
return FileManager.default.fileExists(atPath: url.path) 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. /// Lists all files in a directory.
/// - Parameter directory: The directory to list. /// - Parameter directory: The directory to list.
@ -116,6 +171,13 @@ actor FileStorageHelper {
throw StorageError.fileError(error) 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. /// Gets the size of a file in bytes.
/// - Parameters: /// - Parameters:
@ -140,6 +202,18 @@ actor FileStorageHelper {
throw StorageError.fileError(error) 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 // MARK: - Private Helpers
@ -158,4 +232,87 @@ actor FileStorageHelper {
throw StorageError.fileError(error) 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 { switch domain {
case .userDefaults(let suite): case .userDefaults(let suite):
return "userDefaults(\(suite ?? "standard"))" return "userDefaults(\(suite ?? "standard"))"
case .appGroupUserDefaults(let identifier):
return "appGroupUserDefaults(\(identifier))"
case .keychain(let service): case .keychain(let service):
return "keychain(\(service))" return "keychain(\(service))"
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):
return "appGroupFileSystem(\(identifier), \(string(for: directory)))"
} }
} }

View File

@ -85,10 +85,18 @@ public actor StorageRouter: StorageProviding {
switch key.domain { switch key.domain {
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):
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: identifier)
case .keychain(let service): case .keychain(let service):
return try await KeychainHelper.shared.exists(service: service, key: key.name) return try await KeychainHelper.shared.exists(service: service, 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):
return await FileStorageHelper.shared.exists(
in: directory,
fileName: key.name,
appGroupIdentifier: identifier
)
} }
} }
@ -207,6 +215,9 @@ public actor StorageRouter: StorageProviding {
case .userDefaults(let suite): case .userDefaults(let suite):
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):
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: identifier)
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
@ -234,6 +245,15 @@ public actor StorageRouter: StorageProviding {
fileName: key.name, fileName: key.name,
useCompleteFileProtection: true 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 { switch key.domain {
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):
return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: identifier)
case .keychain(let service): case .keychain(let service):
return try await KeychainHelper.shared.get(service: service, key: key.name) return try await KeychainHelper.shared.get(service: service, 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):
return try await FileStorageHelper.shared.read(
from: directory,
fileName: key.name,
appGroupIdentifier: identifier
)
} }
} }
@ -254,12 +282,20 @@ public actor StorageRouter: StorageProviding {
switch key.domain { switch key.domain {
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):
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: identifier)
case .keychain(let service): case .keychain(let service):
try await KeychainHelper.shared.delete(service: service, key: key.name) try await KeychainHelper.shared.delete(service: service, 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):
try await FileStorageHelper.shared.delete(
from: directory,
fileName: key.name,
appGroupIdentifier: identifier
)
} }
} }