LocalData/Sources/LocalData/Helpers/FileStorageHelper.swift
Matt Bruce bd793619c4 Update Audit, Configuration, Helpers (+3 more)
Summary:
- Sources: update Audit, Configuration, Helpers (+3 more)

Stats:
- 14 files changed, 290 insertions(+), 17 deletions(-)
2026-01-18 13:43:12 -06:00

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