Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-14 16:13:02 -06:00
parent 202ed6063b
commit ab95353119
3 changed files with 82 additions and 68 deletions

View File

@ -30,6 +30,7 @@ actor FileStorageHelper {
let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier)
let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory)
let url = directoryURL.appendingPathComponent(fileName) let url = directoryURL.appendingPathComponent(fileName)
Logger.debug("Writing to App Group file: \(url.path)")
try ensureDirectoryExists(at: url.deletingLastPathComponent()) try ensureDirectoryExists(at: url.deletingLastPathComponent())
try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection) try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection)
} }
@ -47,22 +48,15 @@ actor FileStorageHelper {
fileName: String, fileName: String,
useCompleteFileProtection: Bool = false useCompleteFileProtection: Bool = false
) throws { ) throws {
let url = directory.url().appendingPathComponent(fileName) let directoryURL = try resolveDirectoryURL(directory: directory)
let url = directoryURL.appendingPathComponent(fileName)
Logger.debug("Writing file: \(url.path)")
// Ensure directory exists // Ensure directory exists
try ensureDirectoryExists(at: url.deletingLastPathComponent()) try ensureDirectoryExists(at: url.deletingLastPathComponent())
// Write with appropriate options // Write with appropriate options
var options: Data.WritingOptions = [.atomic] try write(data, to: url, useCompleteFileProtection: useCompleteFileProtection)
if useCompleteFileProtection {
options.insert(.completeFileProtection)
}
do {
try data.write(to: url, options: options)
} catch {
throw StorageError.fileError(error)
}
} }
/// Reads data from a file. /// Reads data from a file.
@ -75,17 +69,10 @@ actor FileStorageHelper {
from directory: FileDirectory, from directory: FileDirectory,
fileName: String fileName: String
) throws -> Data? { ) throws -> Data? {
let url = directory.url().appendingPathComponent(fileName) let directoryURL = try resolveDirectoryURL(directory: directory)
let url = directoryURL.appendingPathComponent(fileName)
guard FileManager.default.fileExists(atPath: url.path) else { Logger.debug("Reading file: \(url.path)")
return nil return try read(from: url)
}
do {
return try Data(contentsOf: url)
} catch {
throw StorageError.fileError(error)
}
} }
/// Reads data from an App Group container. /// Reads data from an App Group container.
@ -97,6 +84,7 @@ actor FileStorageHelper {
let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier) let baseURL = try appGroupContainerURL(identifier: appGroupIdentifier)
let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory) let directoryURL = try resolveDirectoryURL(baseURL: baseURL, directory: directory)
let url = directoryURL.appendingPathComponent(fileName) let url = directoryURL.appendingPathComponent(fileName)
Logger.debug("Reading App Group file: \(url.path)")
return try read(from: url) return try read(from: url)
} }
@ -109,17 +97,9 @@ actor FileStorageHelper {
from directory: FileDirectory, from directory: FileDirectory,
fileName: String fileName: String
) throws { ) throws {
let url = directory.url().appendingPathComponent(fileName) let directoryURL = try resolveDirectoryURL(directory: directory)
let url = directoryURL.appendingPathComponent(fileName)
guard FileManager.default.fileExists(atPath: url.path) else { try delete(file: url)
return // File doesn't exist, nothing to delete
}
do {
try FileManager.default.removeItem(at: url)
} catch {
throw StorageError.fileError(error)
}
} }
/// Deletes a file from an App Group container. /// Deletes a file from an App Group container.
@ -143,8 +123,13 @@ actor FileStorageHelper {
in directory: FileDirectory, in directory: FileDirectory,
fileName: String fileName: String
) -> Bool { ) -> Bool {
let url = directory.url().appendingPathComponent(fileName) do {
return FileManager.default.fileExists(atPath: url.path) 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. /// Checks if a file exists in an App Group container.
@ -168,17 +153,8 @@ actor FileStorageHelper {
/// - Returns: An array of file names. /// - Returns: An array of file names.
/// - Throws: `StorageError.fileError` if listing fails. /// - Throws: `StorageError.fileError` if listing fails.
public func list(in directory: FileDirectory) throws -> [String] { public func list(in directory: FileDirectory) throws -> [String] {
let url = directory.url() let directoryURL = try resolveDirectoryURL(directory: directory)
return try list(in: directoryURL)
guard FileManager.default.fileExists(atPath: url.path) else {
return []
}
do {
return try FileManager.default.contentsOfDirectory(atPath: url.path)
} catch {
throw StorageError.fileError(error)
}
} }
/// Lists all files in an App Group container directory. /// Lists all files in an App Group container directory.
@ -198,18 +174,9 @@ actor FileStorageHelper {
of directory: FileDirectory, of directory: FileDirectory,
fileName: String fileName: String
) throws -> Int64? { ) throws -> Int64? {
let url = directory.url().appendingPathComponent(fileName) let directoryURL = try resolveDirectoryURL(directory: directory)
let url = directoryURL.appendingPathComponent(fileName)
guard FileManager.default.fileExists(atPath: url.path) else { return try size(of: url)
return nil
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return attributes[.size] as? Int64
} catch {
throw StorageError.fileError(error)
}
} }
/// Gets the size of a file in an App Group container. /// Gets the size of a file in an App Group container.
@ -232,12 +199,14 @@ actor FileStorageHelper {
} }
do { do {
Logger.debug("Ensuring directory exists: \(url.path)")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url, at: url,
withIntermediateDirectories: true, withIntermediateDirectories: true,
attributes: nil attributes: nil
) )
} catch { } catch {
Logger.error("Failed to create directory", error: error)
throw StorageError.fileError(error) throw StorageError.fileError(error)
} }
} }
@ -250,7 +219,9 @@ actor FileStorageHelper {
do { do {
try data.write(to: url, options: options) try data.write(to: url, options: options)
Logger.debug("Successfully wrote \(data.count) bytes to \(url.lastPathComponent)")
} catch { } catch {
Logger.error("Failed to write to \(url.path)", error: error)
throw StorageError.fileError(error) throw StorageError.fileError(error)
} }
} }
@ -308,21 +279,27 @@ actor FileStorageHelper {
guard let url = FileManager.default.containerURL( guard let url = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: identifier forSecurityApplicationGroupIdentifier: identifier
) else { ) else {
Logger.error("Invalid App Group identifier: \(identifier)")
throw StorageError.invalidAppGroupIdentifier(identifier) throw StorageError.invalidAppGroupIdentifier(identifier)
} }
Logger.debug("Resolved App Group container: \(url.path)")
return url return url
} }
private func resolveDirectoryURL(baseURL: URL, directory: FileDirectory) throws -> URL { private func resolveDirectoryURL(baseURL: URL? = nil, directory: FileDirectory) throws -> URL {
let base: URL let base: URL
switch directory { if let baseURL = baseURL {
case .documents: switch directory {
base = baseURL.appending(path: "Documents") case .documents:
case .caches: base = baseURL.appending(path: "Documents")
base = baseURL.appending(path: "Library/Caches") case .caches:
case .custom(let url): base = baseURL.appending(path: "Library/Caches")
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) case .custom(let url):
return baseURL.appending(path: relativePath) let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return baseURL.appending(path: relativePath)
}
} else {
base = directory.url()
} }
if let subDirectory = configuration.subDirectory { if let subDirectory = configuration.subDirectory {

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
#if os(iOS) || os(watchOS) #if os(iOS) || os(watchOS)
import WatchConnectivity import WatchConnectivity
#endif #endif
@ -66,6 +67,7 @@ public actor StorageRouter: StorageProviding {
/// - key: The storage key defining where and how to store. /// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy. /// - Throws: Various errors depending on the storage domain and security policy.
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws { public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -74,6 +76,7 @@ public actor StorageRouter: StorageProviding {
try await store(securedData, for: key) try await store(securedData, for: key)
try await handleSync(key, data: securedData) try await handleSync(key, data: securedData)
Logger.debug("<<< [STORAGE] SET SUCCESS: \(key.name)")
} }
/// Retrieves a value for the given key. /// Retrieves a value for the given key.
@ -81,15 +84,19 @@ public actor StorageRouter: StorageProviding {
/// - Returns: The stored value. /// - Returns: The stored value.
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors. /// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value { public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
Logger.debug(">>> [STORAGE] GET: \(key.name)")
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
guard let securedData = try await retrieve(for: key) else { guard let securedData = try await retrieve(for: key) else {
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound throw StorageError.notFound
} }
let data = try await applySecurity(securedData, for: key, isEncrypt: false) let data = try await applySecurity(securedData, for: key, isEncrypt: false)
return try deserialize(data, with: key.serializer) let result = try deserialize(data, with: key.serializer)
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
return result
} }
/// Removes the value for the given key. /// Removes the value for the given key.
@ -352,15 +359,19 @@ public actor StorageRouter: StorageProviding {
private func resolveService(_ service: String?) throws -> String { private func resolveService(_ service: String?) throws -> String {
guard let resolved = service ?? storageConfiguration.defaultKeychainService else { guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
Logger.error("No keychain service provided and no default configured")
throw StorageError.keychainError(errSecBadReq) // Or a more specific error throw StorageError.keychainError(errSecBadReq) // Or a more specific error
} }
Logger.debug("Resolved Keychain Service: \(resolved)")
return resolved return resolved
} }
private func resolveIdentifier(_ identifier: String?) throws -> String { private func resolveIdentifier(_ identifier: String?) throws -> String {
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else { guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
Logger.error("No App Group identifier provided and no default configured")
throw StorageError.invalidAppGroupIdentifier("none") throw StorageError.invalidAppGroupIdentifier("none")
} }
Logger.debug("Resolved App Group ID: \(resolved)")
return resolved return resolved
} }
} }

View File

@ -0,0 +1,26 @@
import Foundation
/// Internal logging utility for the LocalData package.
enum Logger {
static var isLoggingEnabled = true
static func debug(_ message: String) {
#if DEBUG
if isLoggingEnabled {
print(" {LOCAL_DATA} \(message)")
}
#endif
}
static func error(_ message: String, error: Error? = nil) {
#if DEBUG
var logMessage = " {LOCAL_DATA} ❌ \(message)"
if let error = error {
logMessage += " | Error: \(error.localizedDescription)"
}
if isLoggingEnabled {
print(logMessage)
}
#endif
}
}