Summary: - Sources: update Audit, Configuration, Helpers (+3 more) Stats: - 14 files changed, 290 insertions(+), 17 deletions(-)
410 lines
16 KiB
Swift
410 lines
16 KiB
Swift
import Foundation
|
|
|
|
/// Actor that handles all file system operations.
|
|
///
|
|
/// Provides thread-safe file reading, writing, deletion, and listing for
|
|
/// app sandbox and App Group containers, with optional subdirectory scoping.
|
|
actor FileStorageHelper {
|
|
|
|
/// Shared file storage helper instance.
|
|
///
|
|
/// Prefer this shared instance in production code. Tests can inject a custom instance.
|
|
public static let shared = FileStorageHelper()
|
|
|
|
/// Current file storage configuration.
|
|
private var configuration: FileStorageConfiguration
|
|
|
|
/// Creates a helper with a specific configuration.
|
|
///
|
|
/// - Parameter configuration: File storage configuration to apply.
|
|
internal init(configuration: FileStorageConfiguration = .default) {
|
|
self.configuration = configuration
|
|
}
|
|
|
|
/// Updates the file storage configuration.
|
|
///
|
|
/// - Parameter configuration: New configuration to apply.
|
|
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
|
|
self.configuration = configuration
|
|
}
|
|
|
|
// MARK: - Public Interface
|
|
|
|
/// Writes data to an App Group container.
|
|
///
|
|
/// - Parameters:
|
|
/// - data: The data to write.
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - useCompleteFileProtection: Whether to use iOS complete file protection.
|
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
|
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)
|
|
Logger.debug("Writing to App Group file: \(url.path)")
|
|
try ensureDirectoryExists(at: url.deletingLastPathComponent())
|
|
try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection)
|
|
}
|
|
|
|
/// Writes data to a file.
|
|
/// - Parameters:
|
|
/// - data: The data to write.
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - useCompleteFileProtection: Whether to use iOS complete file protection.
|
|
/// - Throws: `StorageError.fileError` if the operation fails.
|
|
public func write(
|
|
_ data: Data,
|
|
to directory: FileDirectory,
|
|
fileName: String,
|
|
useCompleteFileProtection: Bool = false
|
|
) throws {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
let url = directoryURL.appendingPathComponent(fileName)
|
|
Logger.debug("Writing file: \(url.path)")
|
|
|
|
// Ensure directory exists
|
|
try ensureDirectoryExists(at: url.deletingLastPathComponent())
|
|
|
|
// Write with appropriate options
|
|
try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection)
|
|
}
|
|
|
|
/// Reads data from a file.
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - Returns: The file contents, or nil if the file doesn't exist.
|
|
/// - Throws: `StorageError.fileError` if reading fails.
|
|
public func read(
|
|
from directory: FileDirectory,
|
|
fileName: String
|
|
) throws -> Data? {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
let url = directoryURL.appendingPathComponent(fileName)
|
|
Logger.debug("Reading file: \(url.path)")
|
|
return try read(from: url)
|
|
}
|
|
|
|
/// Reads data from an App Group container.
|
|
///
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - Returns: The file contents, or `nil` if the file doesn't exist.
|
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
|
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)
|
|
Logger.debug("Reading App Group file: \(url.path)")
|
|
return try read(from: url)
|
|
}
|
|
|
|
/// Deletes a file.
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - Throws: `StorageError.fileError` if deletion fails.
|
|
public func delete(
|
|
from directory: FileDirectory,
|
|
fileName: String
|
|
) throws {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
let url = directoryURL.appendingPathComponent(fileName)
|
|
try delete(file: url)
|
|
}
|
|
|
|
/// Deletes a file from an App Group container.
|
|
///
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
|
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.
|
|
/// - fileName: The file name within the directory.
|
|
/// - Returns: True if the file exists.
|
|
public func exists(
|
|
in directory: FileDirectory,
|
|
fileName: String
|
|
) -> Bool {
|
|
do {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
let url = directoryURL.appendingPathComponent(fileName)
|
|
return FileManager.default.fileExists(atPath: url.path)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Checks if a file exists in an App Group container.
|
|
///
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - Returns: `true` if the file exists.
|
|
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.
|
|
/// - Throws: `StorageError.fileError` if listing fails.
|
|
public func list(in directory: FileDirectory) throws -> [String] {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
return try list(in: directoryURL)
|
|
}
|
|
|
|
/// Lists all files in an App Group container directory.
|
|
///
|
|
/// - Parameters:
|
|
/// - directory: The directory to list.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - Returns: An array of file names.
|
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
|
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.
|
|
/// - fileName: The file name within the directory.
|
|
/// - Returns: The file size in bytes, or nil if the file doesn't exist.
|
|
/// - Throws: `StorageError.fileError` if getting attributes fails.
|
|
public func size(
|
|
of directory: FileDirectory,
|
|
fileName: String
|
|
) throws -> Int64? {
|
|
let directoryURL = try resolveDirectoryURL(directory: directory)
|
|
let url = directoryURL.appendingPathComponent(fileName)
|
|
return try size(of: url)
|
|
}
|
|
|
|
/// Gets the size of a file in an App Group container.
|
|
///
|
|
/// - Parameters:
|
|
/// - directory: The base directory.
|
|
/// - fileName: The file name within the directory.
|
|
/// - appGroupIdentifier: App Group identifier.
|
|
/// - Returns: The file size in bytes, or `nil` if the file doesn't exist.
|
|
/// - Throws: ``StorageError/fileError(_:)`` or ``StorageError/invalidAppGroupIdentifier(_:)``.
|
|
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
|
|
|
|
/// Ensures the parent directory exists before writing a file.
|
|
///
|
|
/// - Parameter url: Directory URL to create if missing.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if creation fails.
|
|
private func ensureDirectoryExists(at url: URL) throws {
|
|
guard !FileManager.default.fileExists(atPath: url.path) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
Logger.debug("Ensuring directory exists: \(url.path)")
|
|
try FileManager.default.createDirectory(
|
|
at: url,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
} catch {
|
|
Logger.error("Failed to create directory", error: error)
|
|
throw StorageError.fileError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Writes data to a URL with optional file protection.
|
|
///
|
|
/// - Parameters:
|
|
/// - data: The data to write.
|
|
/// - url: Destination URL.
|
|
/// - useCompleteFileProtection: Whether to apply complete file protection.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if write fails.
|
|
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)
|
|
Logger.debug("Successfully wrote \(data.count) bytes to \(url.lastPathComponent)")
|
|
} catch {
|
|
Logger.error("Failed to write to \(url.path)", error: error)
|
|
throw StorageError.fileError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Reads data from a URL if it exists.
|
|
///
|
|
/// - Parameter url: File URL to read.
|
|
/// - Returns: File data if present, otherwise `nil`.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if read fails.
|
|
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.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Deletes a file if it exists.
|
|
///
|
|
/// - Parameter url: File URL to delete.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if deletion fails.
|
|
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.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Lists file names in a directory URL.
|
|
///
|
|
/// - Parameter url: Directory URL to list.
|
|
/// - Returns: File names in the directory.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if listing fails.
|
|
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.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Returns the size of a file at a URL.
|
|
///
|
|
/// - Parameter url: File URL to measure.
|
|
/// - Returns: File size in bytes, or `nil` if missing.
|
|
/// - Throws: ``StorageError/fileError(_:)`` if attributes fail.
|
|
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.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Resolves the App Group container URL or throws if invalid.
|
|
///
|
|
/// - Parameter identifier: App Group identifier.
|
|
/// - Returns: Container URL for the App Group.
|
|
/// - Throws: ``StorageError/invalidAppGroupIdentifier(_:)`` if unresolved.
|
|
private func appGroupContainerURL(identifier: String) throws -> URL {
|
|
guard let url = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: identifier
|
|
) else {
|
|
Logger.error("Invalid App Group identifier: \(identifier)")
|
|
throw StorageError.invalidAppGroupIdentifier(identifier)
|
|
}
|
|
Logger.debug("Resolved App Group container: \(url.path)")
|
|
return url
|
|
}
|
|
|
|
/// Resolves the final directory URL, applying overrides and subdirectory settings.
|
|
///
|
|
/// - Parameters:
|
|
/// - overrideURL: Optional base URL override.
|
|
/// - directory: Target file directory.
|
|
/// - Returns: Resolved directory URL.
|
|
private func resolveDirectoryURL(baseURL overrideURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
|
let base: URL
|
|
// Priority: 1. Method override, 2. Configuration override, 3. System default
|
|
if let explicitBase = overrideURL ?? configuration.baseURL {
|
|
switch directory {
|
|
case .documents:
|
|
base = explicitBase.appending(path: "Documents")
|
|
case .caches:
|
|
base = explicitBase.appending(path: "Library/Caches")
|
|
case .custom(let url):
|
|
// If it's a custom URL, we treat it as relative to the base if it's not absolute or just use it.
|
|
// But for isolation, if baseURL is set, we might want to nest it.
|
|
// For now, let's keep custom as is OR nest if it looks relative.
|
|
if url.isFileURL && url.path.hasPrefix("/") {
|
|
return url
|
|
}
|
|
return explicitBase.appending(path: url.path)
|
|
}
|
|
} else {
|
|
base = directory.url()
|
|
}
|
|
|
|
if let subDirectory = configuration.subDirectory {
|
|
return base.appending(path: subDirectory)
|
|
}
|
|
|
|
return base
|
|
}
|
|
}
|