// // ImageService.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Foundation import Logging #if canImport(FoundationNetworking) import FoundationNetworking #endif public actor ImageService { private let cacheDir: URL private let memoryCache = NSCache() private var activeDownloads = 0 private var imagesDownloaded = 0 private let maxConcurrent: Int private var pendingContinuations: [CheckedContinuation] = [] private var logger: Logger = .init(label: "dev.bscubed.Luminate") public init(cacheDir: URL? = nil, maxConcurrent: Int = 6) { let defaultCache = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first!.appendingPathComponent("luminate/images") self.cacheDir = cacheDir ?? defaultCache self.maxConcurrent = maxConcurrent try? FileManager.default.createDirectory( at: self.cacheDir, withIntermediateDirectories: true) logger.debug("New ImageService created") } public func loadImage(url: URL) async throws -> Data { let key = url.absoluteString as NSString if let cached = memoryCache.object(forKey: key) { logger.debug("Hit memory cache for \(key)") return cached as Data } let diskURL = diskCacheURL(for: url) if let data = try? Data(contentsOf: diskURL) { memoryCache.setObject(data as NSData, forKey: key) logger.debug("Hit disk cache for \(key)") return data } if activeDownloads >= maxConcurrent { await withCheckedContinuation { (continuation: CheckedContinuation) in logger.debug("Idk what this is doing tbh") pendingContinuations.append(continuation) } } activeDownloads += 1 defer { activeDownloads -= 1 if !pendingContinuations.isEmpty { let next = pendingContinuations.removeFirst() next.resume() } } logger.debug("Started downloading \(url)") let (data, _) = try await URLSession.shared.data(from: url) let nsData = data as NSData memoryCache.setObject(nsData, forKey: key) try? data.write(to: diskURL) logger.debug("Finished downloading \(url)") return data } public func prefetch(urls: [URL]) async { logger.debug("Prefetching \(urls)") await withTaskGroup(of: Void.self) { group in for url in urls { group.addTask { _ = try? await self.loadImage(url: url) } } } } public func clearCache() { logger.debug("Clearing all cache...") memoryCache.removeAllObjects() try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } private func diskCacheURL(for url: URL) -> URL { let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString() .replacingOccurrences(of: "/", with: "_") return cacheDir.appendingPathComponent(diskKey) } }