Skip to main content

Overview

ThumbnailCache provides a two-tier caching system for R2 object thumbnails. It combines in-memory caching with disk persistence and implements request coalescing to prevent duplicate fetches. The actor-based design ensures thread-safe access from any context.
Located at: Fiaxe/Services/ThumbnailCache.swift:6

Type Definition

actor ThumbnailCache
Implemented as an actor for thread-safe concurrent access. All methods can be called from any isolation context.

Singleton Instance

static let shared = ThumbnailCache()
Access the shared cache instance.

Methods

thumbnail()

Retrieves or generates a thumbnail for an R2 object key.
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage?
key
String
required
The R2 object key to generate a thumbnail for (e.g., "photos/image.jpg").
credentials
R2Credentials
required
R2 credentials used to generate presigned URLs for fetching the image.
Returns: Thumbnail image (120×120 max), or nil if generation fails.

Implementation Details

// Example from ThumbnailCache.swift:30-64
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage? {
    // Scope cache key by bucket so different buckets never share thumbnails
    let scopedKey = "\(credentials.bucketName)/\(key)"
    let cacheKey = scopedKey as NSString

    // 1. Memory cache hit
    if let img = memoryCache.object(forKey: cacheKey) { return img }

    // 2. Disk cache hit
    if let img = loadFromDisk(key: scopedKey) {
        memoryCache.setObject(img, forKey: cacheKey)
        return img
    }

    // 3. Coalesce in-flight requests
    if let task = inFlight[scopedKey] { return await task.value }

    // Generate presigned URL — works whether or not a custom domain is set
    guard let url = AWSV4Signer.presignedURL(for: key, credentials: credentials) else {
        return nil
    }

    let task = Task<NSImage?, Never> {
        let img = await fetchThumbnail(url: url, key: key)
        if let img {
            memoryCache.setObject(img, forKey: cacheKey)
            saveToDisk(img, key: scopedKey)
        }
        return img
    }
    inFlight[scopedKey] = task
    let result = await task.value
    inFlight.removeValue(forKey: scopedKey)
    return result
}
The method implements a three-tier lookup:
  1. Memory cache - Instant return if cached in NSCache
  2. Disk cache - Load from disk if available, promote to memory
  3. Network fetch - Generate thumbnail from R2, cache in both memory and disk
Request CoalescingIf multiple requests for the same thumbnail arrive simultaneously, only one network request is made. Other requests await the same task result.

clearMemory()

Clears the entire in-memory cache.
func clearMemory()
Useful for freeing memory during low-memory conditions. Disk cache is preserved.

Cache Configuration

The cache is configured with size limits:
private init() {
    memoryCache.countLimit = 500           // Max 500 images
    memoryCache.totalCostLimit = 100 * 1024 * 1024  // Max 100 MB
}
countLimit
Int
Maximum number of images in memory cache (500).
totalCostLimit
Int
Maximum memory footprint in bytes (100 MB).
NSCache automatically evicts least-recently-used items when limits are exceeded.

Disk Cache

Thumbnails are persisted to the user’s cache directory:
private let diskCacheURL: URL = {
    let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let dir = caches.appendingPathComponent("R2VaultThumbnails", isDirectory: true)
    try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    return dir
}()
Disk cache location:
~/Library/Caches/R2VaultThumbnails/

File Naming

Cache keys are sanitized for safe filenames:
private func diskURL(for key: String) -> URL {
    // Use a safe filename derived from the key
    let safe = key
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: ":", with: "_")
    return diskCacheURL.appendingPathComponent(safe + ".png")
}
Examples:
  • photos/vacation.jpgphotos_vacation.jpg.png
  • bucket:folder/file.pngbucket_folder_file.png.png

Storage Format

All thumbnails are stored as PNG:
private func saveToDisk(_ image: NSImage, key: String) {
    guard let tiff = image.tiffRepresentation,
          let rep = NSBitmapImageRep(data: tiff),
          let png = rep.representation(using: .png, properties: [:])
    else { return }
    try? png.write(to: diskURL(for: key))
}
PNG format provides lossless compression suitable for thumbnails.

Thumbnail Generation

Image Thumbnails

Generated by downloading and resizing:
private func imageThumbnail(url: URL) async -> NSImage? {
    guard let (data, _) = try? await URLSession.shared.data(from: url),
          let src = NSImage(data: data)
    else { return nil }
    return resized(src, to: CGSize(width: 120, height: 120))
}

Video Thumbnails

Generated using AVFoundation:
private func videoThumbnail(url: URL) async -> NSImage? {
    let asset = AVURLAsset(url: url)
    let gen = AVAssetImageGenerator(asset: asset)
    gen.appliesPreferredTrackTransform = true
    gen.maximumSize = CGSize(width: 120, height: 120)
    guard let cgImage = try? await gen.image(at: .zero).image
    else { return nil }
    return NSImage(cgImage: cgImage, size: NSSize(width: 120, height: 120))
}
Extracts the first frame from video files.

Supported Images

JPEG, PNG, GIF, HEIC, BMP, TIFF, WebP

Supported Videos

MP4, MOV, AVI, MKV, WebM, M4V

Resize Algorithm

The cache maintains aspect ratio while fitting within 120×120:
private func resized(_ image: NSImage, to size: CGSize) -> NSImage {
    let original = image.size
    guard original.width > 0, original.height > 0 else { return image }

    let scale = min(size.width / original.width, size.height / original.height)
    let newSize = CGSize(width: original.width * scale, height: original.height * scale)

    let result = NSImage(size: newSize)
    result.lockFocus()
    image.draw(in: CGRect(origin: .zero, size: newSize),
               from: CGRect(origin: .zero, size: original),
               operation: .copy, fraction: 1)
    result.unlockFocus()
    return result
}
Examples:
  • 1920×1080 → 120×67.5 (fits width)
  • 800×1200 → 80×120 (fits height)
  • 100×100 → 100×100 (no upscaling)

Request Coalescing

Prevents duplicate fetches for the same thumbnail:
private var inFlight: [String: Task<NSImage?, Never>] = [:]

// 3. Coalesce in-flight requests
if let task = inFlight[scopedKey] { return await task.value }

let task = Task<NSImage?, Never> {
    let img = await fetchThumbnail(url: url, key: key)
    // ... cache and return
}
inFlight[scopedKey] = task
let result = await task.value
inFlight.removeValue(forKey: scopedKey)
If 10 views request the same thumbnail simultaneously:
  • Only 1 network request is made
  • All 10 requests await the same task
  • All receive the same result

Bucket Scoping

Cache keys are scoped by bucket name:
let scopedKey = "\(credentials.bucketName)/\(key)"
This ensures:
  • Different buckets with same key names don’t share thumbnails
  • Switching between buckets doesn’t show wrong thumbnails
  • Cache hits are always bucket-specific

Usage Example

import SwiftUI

struct ThumbnailView: View {
    let object: R2Object
    let credentials: R2Credentials
    @State private var thumbnail: NSImage?
    
    var body: some View {
        Group {
            if let thumbnail {
                Image(nsImage: thumbnail)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 120, height: 120)
            } else {
                ProgressView()
                    .frame(width: 120, height: 120)
            }
        }
        .task {
            // Automatically fetches thumbnail
            thumbnail = await ThumbnailCache.shared.thumbnail(
                for: object.key,
                credentials: credentials
            )
        }
    }
}

Performance Characteristics

NSCache automatically evicts items under memory pressure. The 100 MB limit prevents excessive memory usage.
PNG compression reduces disk space. A 120×120 thumbnail typically uses 10-50 KB.
Actor isolation ensures safe concurrent access. Multiple views can request thumbnails simultaneously without race conditions.
Request coalescing prevents duplicate downloads. Once cached, thumbnails load instantly.

Cache Persistence

Disk Cache LifetimeThe disk cache persists across app launches and remains until:
  • User manually clears cache (if implemented)
  • macOS automatically clears ~/Library/Caches during cleanup
  • App is uninstalled
Memory cache is cleared on every app launch.

Error Handling

The cache gracefully handles errors:
func thumbnail(for key: String, credentials: R2Credentials) async -> NSImage? {
    // Returns nil on failure
}
Failure cases:
  • Invalid presigned URL
  • Network errors
  • Unsupported file formats
  • Corrupted images
  • Disk write failures
All failures return nil rather than throwing errors, allowing UI to show fallback icons.

Thread Safety

Actor IsolationThe actor keyword ensures all cache operations are serialized:
  • No race conditions on inFlight dictionary
  • Safe NSCache access (though NSCache is already thread-safe)
  • Predictable behavior under concurrent load
// Safe from any context
Task.detached {
    let thumb = await ThumbnailCache.shared.thumbnail(for: key, credentials: creds)
}