ForgeFileStore
File storage for binary data — images, videos, PDFs, JSON responses, downloads. All paths are relative to the store's base directory. The store handles directory creation, file protection, and path resolution.
import ForgeFileStore FileStoring Protocol
All file stores conform to FileStoring. Depend on the protocol for testability.
public protocol FileStoring: Sendable {
// Write
func save(_ data: Data, to path: String, overwrite: Bool) async throws
func copy(from sourceURL: URL, to path: String, overwrite: Bool) async throws
func move(from sourceURL: URL, to path: String, overwrite: Bool) async throws
// Read
func load(_ path: String) async throws -> Data
func url(for path: String) -> URL
// Query
func exists(_ path: String) -> Bool
func info(for path: String) throws -> StoredFile
func list(in directory: String?, recursive: Bool) async throws -> [StoredFile]
func size(of path: String) throws -> UInt64
func totalSize() async throws -> UInt64
// Delete
func delete(_ path: String) async throws
func deleteDirectory(_ path: String) async throws
func deleteAll() async throws
// Directory
func createDirectory(_ path: String) async throws
} DiskFileStore
Production implementation. Each instance manages a namespaced subdirectory within a base iOS directory. Multiple stores can coexist without collision.
let store = DiskFileStore(
directory: .caches,
namespace: "taskflow-cache",
fileProtection: .completeUntilFirstUserAuthentication // default
) Directory Options
| Directory | Backed Up | Purged by System | Use Case |
|---|---|---|---|
.documents | Yes | No | User-created files (photos, exports, recordings) |
.applicationSupport | Yes | No | App-managed data (downloaded assets, generated files) |
.caches | No | Yes (low disk) | Rebuildable data (thumbnails, API responses) |
Namespace
The namespace creates an isolated subdirectory. Use different namespaces for different concerns.
// Permanent user files (task attachments, exports)
let documents = DiskFileStore(directory: .documents, namespace: "taskflow-files")
// App-managed persistent data
let appData = DiskFileStore(directory: .applicationSupport, namespace: "taskflow-data")
// Rebuildable cache (thumbnails, avatars)
let cache = DiskFileStore(directory: .caches, namespace: "taskflow-cache") File Protection
Applied to the namespace directory and every file written. Use .complete for sensitive files that should be inaccessible when the device is locked.
// Default: accessible after first unlock (good for background tasks)
let store = DiskFileStore(directory: .applicationSupport, namespace: "taskflow-data")
// Complete protection: only accessible when device is unlocked
let sensitive = DiskFileStore(
directory: .documents,
namespace: "taskflow-secure",
fileProtection: .complete
) Write Operations
save(_:to:overwrite:)
Saves raw data to a path. Creates intermediate directories automatically. Defaults to overwriting existing files.
// Basic save (overwrites by default)
try await store.save(imageData, to: "tasks/task_42/attachment.jpg")
// Prevent overwriting
try await store.save(data, to: "exports/project_report.pdf", overwrite: false)
// Throws FileStoreError.fileAlreadyExists if file exists copy(from:to:overwrite:)
Copies a file from an external URL into the store. Use for files from UIDocumentPickerViewController, the photo library, or URLSession downloads.
// Copy from document picker
try await store.copy(from: pickerURL, to: "tasks/task_42/brief.pdf")
// Copy from temp download
let tempURL = try await URLSession.shared.download(from: remoteURL).0
try await store.copy(from: tempURL, to: "downloads/project_assets.zip") move(from:to:overwrite:)
Moves a file into the store. More efficient than copy for large files since no data duplication occurs. The source file is removed after a successful move.
try await store.move(from: tempFileURL, to: "videos/recording.mp4") Read Operations
load(_:)
Loads the raw data for a file. Throws FileStoreError.fileNotFound if the file does not exist.
let data = try await store.load("tasks/task_42/attachment.jpg")
let image = UIImage(data: data) url(for:)
Returns the absolute file URL. Does not verify the file exists — use exists(_:) if needed.
let fileURL = store.url(for: "videos/recording.mp4")
let player = AVPlayer(url: fileURL)
// Share via UIActivityViewController
let activityVC = UIActivityViewController(
activityItems: [fileURL],
applicationActivities: nil
) Query Operations
exists(_:)
Returns true if a file exists at the given path.
if store.exists("tasks/task_42/attachment.jpg") {
let data = try await store.load("tasks/task_42/attachment.jpg")
} info(for:)
Returns a StoredFile struct with metadata. Throws FileStoreError.fileNotFound if the file does not exist.
let info = try store.info(for: "tasks/task_42/attachment.jpg")
print(info.name) // "attachment.jpg"
print(info.relativePath) // "tasks/task_42/attachment.jpg"
print(info.size) // 45_312 (bytes)
print(info.createdAt) // Optional<Date>
print(info.modifiedAt) // Optional<Date>
print(info.isDirectory) // false StoredFile
Metadata struct returned by info(for:) and list(in:recursive:).
public struct StoredFile: Sendable, Equatable {
public let name: String // "photo.jpg"
public let relativePath: String // "avatars/photo.jpg"
public let url: URL // Absolute file URL
public let size: UInt64 // Size in bytes
public let createdAt: Date?
public let modifiedAt: Date?
public let isDirectory: Bool
} list(in:recursive:)
Lists files in a subdirectory. Pass nil for the store root. Set recursive: true to include nested subdirectories.
// List files in a subdirectory
let taskFiles = try await store.list(in: "tasks/task_42")
// List all files recursively
let allFiles = try await store.list(in: nil, recursive: true)
// Default: non-recursive, store root
let rootFiles = try await store.list() size(of:) / totalSize()
Get file or total store size in bytes.
let fileSize = try store.size(of: "tasks/task_42/attachment.jpg") // UInt64
let total = try await store.totalSize() // Total bytes across all files
let megabytes = Double(total) / 1_048_576
print("Cache size: \(String(format: "%.1f", megabytes)) MB") Delete Operations
delete(_:)
Deletes a single file. Throws FileStoreError.fileNotFound if it does not exist.
try await store.delete("tasks/task_42/attachment.jpg") deleteDirectory(_:)
Deletes an entire subdirectory and all its contents.
try await store.deleteDirectory("tasks/task_42") deleteAll()
Deletes all files in the store. Recreates the base directory.
try await store.deleteAll() createDirectory(_:)
Creates a subdirectory. No-op if it already exists.
try await store.createDirectory("projects/q2-launch") Convenience Methods
Extensions on FileStoring for common operations.
saveImage / saveImageAsPNG / loadImage
Available on iOS only (UIKit required).
// Save as JPEG (default quality 0.8)
try await store.saveImage(image, to: "avatars/user_123.jpg")
// Custom compression quality
try await store.saveImage(
image,
to: "photos/high_quality.jpg",
compressionQuality: 0.95
)
// Save as PNG (lossless, supports transparency)
try await store.saveImageAsPNG(icon, to: "icons/app_icon.png")
// Load image
let avatar: UIImage? = try await store.loadImage("avatars/user_123.jpg") saveJSON / loadJSON / loadJSONIfExists
Encode and decode Codable types as JSON files.
struct TaskDetails: Codable {
let title: String
let dueDate: Date
let projectId: String
}
// Save
try await store.saveJSON(taskDetails, to: "tasks/task_42.json")
// Load (throws if file not found)
let task: TaskDetails = try await store.loadJSON("tasks/task_42.json")
// Load if exists (returns nil instead of throwing)
let cached: TaskDetails? = try await store.loadJSONIfExists("tasks/task_42.json")
// Custom encoder/decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try await store.saveJSON(taskDetails, to: "tasks/task_42.json", encoder: encoder) hashedFilename(for:extension:)
Generates a deterministic filename from a string (typically a URL). Useful for cache keys.
let url = "https://example.com/images/photo.jpg"
let filename = store.hashedFilename(for: url, extension: "jpg")
// e.g., "a1b2c3d4e5f6.jpg"
if !store.exists("thumbnails/\(filename)") {
let data = try await downloadImage(from: url)
try await store.save(data, to: "thumbnails/\(filename)")
} FileStoreError
All file operations throw typed errors.
public enum FileStoreError: LocalizedError, Sendable {
case fileNotFound(path: String)
case fileAlreadyExists(path: String)
case writeFailed(path: String, underlying: String)
case deleteFailed(path: String, underlying: String)
case directoryCreationFailed(path: String, underlying: String)
} do {
try await store.save(data, to: "file.txt", overwrite: false)
} catch let error as FileStoreError {
switch error {
case .fileAlreadyExists(let path):
print("Already exists: \(path)")
case .writeFailed(let path, let underlying):
print("Write failed at \(path): \(underlying)")
default:
print(error.localizedDescription)
}
} InMemoryFileStore
Dictionary-backed implementation for tests and previews. No disk I/O. Conforms to the same FileStoring protocol.
let store = InMemoryFileStore()
// Use in tests
try await store.save(Data("test".utf8), to: "notes/test.txt")
#expect(store.exists("notes/test.txt"))
let data = try await store.load("notes/test.txt")
#expect(String(data: data, encoding: .utf8) == "test")
// Clean up
try await store.deleteAll()