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:
parent
28ab0bf475
commit
49f48e134d
10
README.md
10
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -10,6 +10,21 @@ actor FileStorageHelper {
|
|||||||
|
|
||||||
// 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:
|
||||||
/// - data: The data to write.
|
/// - 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.
|
/// Deletes a file.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - directory: The base directory.
|
/// - 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.
|
/// Checks if a file exists.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - directory: The base directory.
|
/// - directory: The base directory.
|
||||||
@ -99,6 +138,22 @@ actor FileStorageHelper {
|
|||||||
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.
|
||||||
/// - Returns: An array of file names.
|
/// - 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.
|
/// Gets the size of a file in bytes.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - directory: The base directory.
|
/// - 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
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func ensureDirectoryExists(at url: URL) throws {
|
private func ensureDirectoryExists(at url: URL) throws {
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user