LocalData/Sources/LocalData/Services/FileStorageHelper.swift
Matt Bruce 71e142f36d Update Services, Utilities
Summary:
- Sources: update Services, Utilities

Stats:
- 3 files changed, 82 insertions(+), 68 deletions(-)
2026-01-18 13:43:09 -06:00

312 lines
11 KiB
Swift

import Foundation
/// Actor that handles all file system operations.
/// Provides thread-safe file reading, writing, and deletion.
actor FileStorageHelper {
public static let shared = FileStorageHelper()
private var configuration: FileStorageConfiguration
private init(configuration: FileStorageConfiguration = .default) {
self.configuration = configuration
}
/// Updates the file storage configuration.
public func updateConfiguration(_ configuration: FileStorageConfiguration) {
self.configuration = configuration
}
// 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)
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.
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.
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.
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.
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.
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 {
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)
}
}
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)
}
}
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 {
Logger.error("Invalid App Group identifier: \(identifier)")
throw StorageError.invalidAppGroupIdentifier(identifier)
}
Logger.debug("Resolved App Group container: \(url.path)")
return url
}
private func resolveDirectoryURL(baseURL: URL? = nil, directory: FileDirectory) throws -> URL {
let base: URL
if let baseURL = baseURL {
switch directory {
case .documents:
base = baseURL.appending(path: "Documents")
case .caches:
base = baseURL.appending(path: "Library/Caches")
case .custom(let url):
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return baseURL.appending(path: relativePath)
}
} else {
base = directory.url()
}
if let subDirectory = configuration.subDirectory {
return base.appending(path: subDirectory)
}
return base
}
}