FS
ForgeStorage

File Cache

A complete task attachment caching service for a TaskFlow productivity app using DiskFileStore with the .caches directory. Covers downloading, caching with hashed filenames, loading cached images, and purging old entries.

ImageCacheService

The service uses FileStoring for testability. Images are stored in a thumbnails subdirectory with hashed filenames derived from the source URL.

ImageCacheService.swift
import Foundation
import ForgeFileStore
import UIKit

protocol ImageCacheServiceProtocol: Sendable {
    func image(for url: URL) async throws -> UIImage?
    func cachedImage(for url: URL) async throws -> UIImage?
    func prefetch(urls: [URL]) async
    func purge(olderThan interval: TimeInterval) async throws
    func clearAll() async throws
    func cacheSize() async throws -> UInt64
}

final class ImageCacheService: ImageCacheServiceProtocol {
    private let store: FileStoring
    private let session: URLSession
    private let subdirectory = "thumbnails"

    init(store: FileStoring, session: URLSession = .shared) {
        self.store = store
        self.session = session
    }

    // MARK: - Cache key

    private func cachePath(for url: URL) -> String {
        let filename = store.hashedFilename(
            for: url.absoluteString,
            extension: url.pathExtension.isEmpty ? "jpg" : url.pathExtension
        )
        return "\(subdirectory)/\(filename)"
    }

    // MARK: - Fetch or load from cache

    /// Returns the cached image if available, otherwise downloads and caches it.
    func image(for url: URL) async throws -> UIImage? {
        let path = cachePath(for: url)

        // Check cache first
        if store.exists(path) {
            return try await store.loadImage(path)
        }

        // Download
        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return nil
        }

        // Cache the downloaded data
        try await store.save(data, to: path)

        return UIImage(data: data)
    }

    /// Returns the cached image only. Does not download if missing.
    func cachedImage(for url: URL) async throws -> UIImage? {
        let path = cachePath(for: url)
        guard store.exists(path) else { return nil }
        return try await store.loadImage(path)
    }

    // MARK: - Prefetch

    /// Downloads and caches multiple images concurrently.
    func prefetch(urls: [URL]) async {
        await withTaskGroup(of: Void.self) { group in
            for url in urls {
                group.addTask {
                    _ = try? await self.image(for: url)
                }
            }
        }
    }

    // MARK: - Cleanup

    /// Deletes cached files older than the specified interval.
    func purge(olderThan interval: TimeInterval) async throws {
        let cutoff = Date().addingTimeInterval(-interval)
        let files = try await store.list(in: subdirectory)

        for file in files {
            if let modified = file.modifiedAt, modified < cutoff {
                try await store.delete(file.relativePath)
            }
        }
    }

    /// Deletes all cached files.
    func clearAll() async throws {
        try await store.deleteDirectory(subdirectory)
    }

    /// Returns the total cache size in bytes.
    func cacheSize() async throws -> UInt64 {
        let files = try await store.list(in: subdirectory)
        return files.reduce(0) { $0 + $1.size }
    }
}

Production Setup

Use DiskFileStore with the .caches directory. The system may purge this directory on low disk space, which is exactly what you want for a cache.

AppSetup.swift
let imageCache = ImageCacheService(
    store: DiskFileStore(directory: .caches, namespace: "taskflow-image-cache")
)

SwiftUI Usage

A reusable async image view that loads from cache first.

CachedAsyncImage.swift
struct CachedAsyncImage: View {
    let url: URL
    let cache: ImageCacheServiceProtocol

    @State private var image: UIImage?
    @State private var isLoading = false

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else if isLoading {
                ProgressView()
            } else {
                Color.gray.opacity(0.2)
            }
        }
        .task {
            guard image == nil else { return }
            isLoading = true
            image = try? await cache.image(for: url)
            isLoading = false
        }
    }
}

Cache Management View

Let users see the cache size and clear it.

CacheSettingsView.swift
struct CacheSettingsView: View {
    let cache: ImageCacheServiceProtocol

    @State private var cacheSize: UInt64 = 0
    @State private var isClearing = false

    var body: some View {
        Section("Cache") {
            HStack {
                Text("Image Cache")
                Spacer()
                Text(formattedSize)
                    .foregroundStyle(.secondary)
            }

            Button("Clear Cache", role: .destructive) {
                Task {
                    isClearing = true
                    try? await cache.clearAll()
                    cacheSize = 0
                    isClearing = false
                }
            }
            .disabled(isClearing || cacheSize == 0)
        }
        .task {
            cacheSize = (try? await cache.cacheSize()) ?? 0
        }
    }

    private var formattedSize: String {
        let mb = Double(cacheSize) / 1_048_576
        if mb < 1 {
            let kb = Double(cacheSize) / 1_024
            return String(format: "%.0f KB", kb)
        }
        return String(format: "%.1f MB", mb)
    }
}

Periodic Cleanup

Purge stale cache entries on app launch.

TaskFlowApp.swift
@main
struct TaskFlowApp: App {
    let imageCache = ImageCacheService(
        store: DiskFileStore(directory: .caches, namespace: "taskflow-image-cache")
    )

    var body: some Scene {
        WindowGroup {
            ContentView(cache: imageCache)
                .task {
                    // Purge images older than 7 days
                    let sevenDays: TimeInterval = 7 * 24 * 60 * 60
                    try? await imageCache.purge(olderThan: sevenDays)
                }
        }
    }
}

Testing

Use InMemoryFileStore for fast, isolated tests with no disk I/O.

ImageCacheServiceTests.swift
import Testing
@testable import TaskFlow

struct ImageCacheServiceTests {
    let store = InMemoryFileStore()
    let cache: ImageCacheService

    init() {
        cache = ImageCacheService(store: store)
    }

    @Test func cachedImageReturnsNilWhenNotCached() async throws {
        let url = URL(string: "https://example.com/photo.jpg")!
        let result = try await cache.cachedImage(for: url)
        #expect(result == nil)
    }

    @Test func cacheSizeStartsAtZero() async throws {
        let size = try await cache.cacheSize()
        #expect(size == 0)
    }

    @Test func clearAllRemovesAllCachedFiles() async throws {
        // Manually save some data to simulate cached images
        try await store.save(Data("img1".utf8), to: "thumbnails/a.jpg")
        try await store.save(Data("img2".utf8), to: "thumbnails/b.jpg")

        try await cache.clearAll()

        let files = try await store.list(in: "thumbnails")
        #expect(files.isEmpty)
    }

    @Test func hashedFilenameIsDeterministic() {
        let url = "https://example.com/photo.jpg"
        let name1 = store.hashedFilename(for: url, extension: "jpg")
        let name2 = store.hashedFilename(for: url, extension: "jpg")
        #expect(name1 == name2)
    }

    @Test func differentURLsProduceDifferentFilenames() {
        let name1 = store.hashedFilename(for: "https://a.com/1.jpg", extension: "jpg")
        let name2 = store.hashedFilename(for: "https://b.com/2.jpg", extension: "jpg")
        #expect(name1 != name2)
    }
}