ImageCache.swift

2.8 KB · raw

1import AppKit23/// In-memory thumbnail cache backed by `NSCache`. NSCache already evicts4/// entries under memory pressure; the explicit `countLimit` and5/// `totalCostLimit` here are the deterministic upper bounds the manager6/// enforces so a runaway explore session can't grow the cache without7/// limit. Both are configurable through `init` so tests can shrink the8/// budget without depending on system-wide memory signals.9class ImageCache {10  static let shared = ImageCache()1112  /// Default cap on the number of cached images. Sixty-four entries is13  /// enough to cover an explore screen of thumbnails plus a screen of14  /// pre-fetched neighbours without keeping multiple megabytes of decoded15  /// pixel data resident indefinitely.16  static let defaultCountLimit = 641718  /// Default cost budget in bytes. `NSImage` does not expose a precise19  /// pixel cost, so we estimate `width * height * 4` (RGBA) when callers20  /// know the source dimensions. The default of ~64 MiB keeps the cache21  /// under a tenth of a typical app's compressed memory allowance.22  static let defaultTotalCostLimit = 64 * 1024 * 10242324  private let cache = NSCache<NSString, NSImage>()25  private let session: URLSession2627  init(28    session: URLSession = .shared,29    countLimit: Int = ImageCache.defaultCountLimit,30    totalCostLimit: Int = ImageCache.defaultTotalCostLimit31  ) {32    self.session = session33    self.cache.countLimit = countLimit34    self.cache.totalCostLimit = totalCostLimit35  }3637  func image(for url: URL) -> NSImage? {38    return cache.object(forKey: url.absoluteString as NSString)39  }4041  func insertImage(_ image: NSImage, for url: URL) {42    cache.setObject(image, forKey: url.absoluteString as NSString, cost: estimatedCost(of: image))43  }4445  func fetchImage(url: URL) async throws -> NSImage {46    if let cached = image(for: url) {47      return cached48    }4950    let (data, response) = try await session.data(from: url)51    guard let response = response as? HTTPURLResponse,52      (200...299).contains(response.statusCode)53    else {54      throw WallhavenError.apiError("Image download failed")55    }56    guard let image = NSImage(data: data) else {57      throw WallhavenError.decodingError(NSError(domain: "ImageCache", code: -1, userInfo: nil))58    }5960    insertImage(image, for: url)61    return image62  }6364  /// Estimated RGBA byte cost of `image`. Used by NSCache so eviction65  /// respects a real budget instead of just the entry count. Returns zero66  /// for images without pixel dimensions (e.g. vector PDFs) so the count67  /// limit alone bounds them.68  private func estimatedCost(of image: NSImage) -> Int {69    let size = image.size70    let width = Int(size.width.rounded())71    let height = Int(size.height.rounded())72    guard width > 0, height > 0 else { return 0 }73    return width * height * 474  }75}