diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..80dec6a --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,27 @@ +# Cache behavior + +Both caches live under `URL.cachesDirectory//`. The identifier comes from the main bundle; unbundled command-line processes use their process name. macOS may reclaim these disposable files. Missing cache files are normal and do not prevent a network download. + +## Catalog index + +The catalog is stored as `index.json`. On the first refresh of a `CatalogStore`, SimStore validates the disk copy and displays it immediately when valid, then requests the current index from the builds repository's `latest` release. Later refreshes keep the current in-memory catalog visible while requesting an update. Entering the catalog and using Refresh Catalog request an update; there is no periodic refresh or time-based expiry. + +Requests bypass Foundation's local HTTP cache and use a 30-second request timeout. SimStore does not implement ETag or Last-Modified conditional requests. A successful HTTP response must also pass catalog decoding and validation before replacing the current catalog and atomically overwriting the disk file. No catalog timestamp comparison is performed. + +A network, HTTP, or validation failure preserves the last valid catalog and displays a saved-catalog notice. If no valid catalog is available, the page offers an error and retry action. An unreadable or invalid disk copy is ignored and can be replaced by a successful download. A disk write failure is logged without discarding a valid downloaded catalog. Concurrent refreshes share one request; an active caller retries if the task owning its shared refresh was canceled. + +## App icons + +`IconCache` is shared by app cards and detail sheets. The key is the SHA-256 digest of the original release-asset URL, a newline, and the latest version's `recipeRevision`. The original URL is used even when GitHub redirects to a temporary asset URL. This digest identifies the cache entry; the catalog's archive SHA-256 is not an icon checksum. + +A new app version or recipe revision changes the key. Description, translation, and category changes do not. Selecting a historical build in the detail sheet still uses the app's latest icon. Display size is not part of the key, so the 52 pt card and 80 pt detail icon share the same decoded image. + +The lookup order is decoded memory cache, disk, then network. ImageIO decodes images off the main thread with a maximum edge of 256 pixels; smaller images retain their original dimensions. The `NSCache` cost is `bytesPerRow * height`, with a target of 32 MiB. This is an eviction hint, not a strict process-memory limit or a preallocated buffer. View and GPU allocations are outside that cost. + +The original downloaded image bytes are saved atomically in `icons/.png` after a successful HTTP response and image decoding. The disk budget is 64 MiB of file contents, rather than allocated filesystem blocks. An entry larger than the entire budget is displayed and cached in memory but is not written to disk. A persistence failure is logged and does not hide a valid image. + +Disk cleanup runs on the first disk access and after a successful write. Modification times record use, including memory hits. When over budget, the least recently used files are removed first. Only regular PNG files with generated 64-character lowercase hexadecimal names participate in cleanup. Old build entries remain until evicted; valid images have no time-based expiry. Neither budget is preallocated. + +Downloads use a dedicated ephemeral `URLSession` with its HTTP cache disabled and a 30-second request timeout. At most four downloads run concurrently. Requests for the same key share one task; canceling one view does not cancel that shared task. Canceled callers discard its result, while the completed download remains available to other views and future cache lookups. + +Corrupt disk images are removed and fetched again. Failed requests and invalid response bodies are not persisted or retained as successful entries. There is no automatic retry loop; a later view request can retry. While no image is available, the existing placeholder remains visible. Refreshing the catalog does not clear icon caches; only a changed key, eviction, or missing/corrupt data requires another download. diff --git a/src/AppConfiguration.swift b/src/AppConfiguration.swift index 0e97425..1e8cd10 100644 --- a/src/AppConfiguration.swift +++ b/src/AppConfiguration.swift @@ -3,6 +3,7 @@ import Foundation enum AppConfiguration { static let applicationIdentifier = Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName + static let cacheDirectory = URL.cachesDirectory.appending(component: applicationIdentifier) static let buildsRepositoryURL = URL(string: "https://github.com/iSimStore/simstore-builds")! static var catalogURL: URL { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0f56feb..77a2284 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -17,6 +17,7 @@ add_executable(SimStore MACOSX_BUNDLE CatalogClient.swift CatalogStore.swift CatalogPresentation.swift + IconCache.swift CatalogBrowserView.swift CatalogAppDetailView.swift SidebarView.swift diff --git a/src/CatalogClient.swift b/src/CatalogClient.swift index 653fb8b..f907817 100644 --- a/src/CatalogClient.swift +++ b/src/CatalogClient.swift @@ -10,8 +10,7 @@ struct CatalogClient: Sendable { static let live = CatalogClient( url: AppConfiguration.catalogURL, - cacheURL: URL.cachesDirectory.appending(component: AppConfiguration.applicationIdentifier) - .appending(component: "index.json") + cacheURL: AppConfiguration.cacheDirectory.appending(component: "index.json") ) init( diff --git a/src/CatalogPresentation.swift b/src/CatalogPresentation.swift index 8f562b3..c463545 100644 --- a/src/CatalogPresentation.swift +++ b/src/CatalogPresentation.swift @@ -35,14 +35,20 @@ extension CatalogVersion { struct CatalogIcon: View { let app: CatalogApp var size: CGFloat = 52 + @State private var image: CGImage? - var body: some View { - AsyncImage( + private var key: IconCache.Key { + IconCache.Key( url: AppConfiguration.assetURL( - appID: app.id, version: app.latestVersion.version, filename: "icon.png") - ) { phase in - if let image = phase.image { - image.resizable().scaledToFit() + appID: app.id, version: app.latestVersion.version, filename: "icon.png"), + recipeRevision: app.latestVersion.recipeRevision) + } + + var body: some View { + let key = key + Group { + if let image { + Image(decorative: image, scale: 1).resizable().scaledToFit() } else { RoundedRectangle(cornerRadius: size * 0.22) .fill(Color.accentColor.opacity(0.08)) @@ -56,6 +62,15 @@ struct CatalogIcon: View { .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.22)) .accessibilityHidden(true) + .task(id: key) { + guard !Task.isCancelled else { return } + image = nil + do { + let loaded = try await IconCache.shared.image(for: key) + try Task.checkCancellation() + image = loaded + } catch {} + } } } diff --git a/src/IconCache.swift b/src/IconCache.swift new file mode 100644 index 0000000..e98b6b8 --- /dev/null +++ b/src/IconCache.swift @@ -0,0 +1,201 @@ +import CoreGraphics +import CryptoKit +import Foundation +import ImageIO +import OSLog + +actor IconCache { + struct Key: Hashable, Sendable { + let url: URL + let recipeRevision: String + + var filename: String { + SHA256.hash(data: Data("\(url.absoluteString)\n\(recipeRevision)".utf8)) + .map { String(format: "%02x", $0) }.joined() + ".png" + } + } + + typealias Transport = @Sendable (URLRequest) async throws -> (Data, URLResponse) + static let shared = IconCache() + private static let logger = Logger( + subsystem: AppConfiguration.applicationIdentifier, category: "Icons") + + private let directory: URL + private let diskLimit: Int + private let maximumConcurrentDownloads: Int + private let transport: Transport + private let memory = NSCache() + private var requests: [Key: Task] = [:] + private var activeDownloads = 0 + private var downloadWaiters: [CheckedContinuation] = [] + private var hasTrimmedDisk = false + + init( + directory: URL = AppConfiguration.cacheDirectory.appending(component: "icons"), + memoryLimit: Int = 32 * 1024 * 1024, + diskLimit: Int = 64 * 1024 * 1024, + maximumConcurrentDownloads: Int = 4, + transport: Transport? = nil + ) { + precondition(memoryLimit > 0 && diskLimit >= 0 && maximumConcurrentDownloads > 0) + self.directory = directory + self.diskLimit = diskLimit + self.maximumConcurrentDownloads = maximumConcurrentDownloads + memory.totalCostLimit = memoryLimit + if let transport { + self.transport = transport + } else { + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: configuration) + self.transport = { try await session.data(for: $0) } + } + } + + func image(for key: Key) async throws -> CGImage { + try Task.checkCancellation() + let path = directory.appending(component: key.filename) + if let image = memory.object(forKey: key.filename as NSString) { + touch(path) + return image + } + if let request = requests[key] { + let image = try await request.value + try Task.checkCancellation() + return image + } + + // The cache owns this task so one disappearing view cannot cancel other callers. + let request = Task { + do { + return try await load(key, from: path) + } catch { + Self.logger.error( + "Could not load icon \(key.url.absoluteString, privacy: .public): \(String(describing: error), privacy: .public)" + ) + throw error + } + } + requests[key] = request + defer { requests[key] = nil } + let image = try await request.value + try Task.checkCancellation() + return image + } + + private func load(_ key: Key, from path: URL) async throws -> CGImage { + if let data = try? Data(contentsOf: path) { + if let image = Self.decode(data) { + touch(path) + trimDiskOnce() + remember(image, for: key) + return image + } + try? FileManager.default.removeItem(at: path) + } + trimDiskOnce() + + await acquireDownload() + defer { releaseDownload() } + var request = URLRequest(url: key.url, cachePolicy: .reloadIgnoringLocalCacheData) + request.timeoutInterval = 30 + let (data, response) = try await transport(request) + guard let response = response as? HTTPURLResponse else { throw Failure.invalidResponse } + guard (200..<300).contains(response.statusCode) else { + throw Failure.httpStatus(response.statusCode) + } + guard let image = Self.decode(data) else { throw Failure.invalidImage } + remember(image, for: key) + if data.count <= diskLimit { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: path, options: .atomic) + trimDisk() + } catch { + Self.logger.error( + "Could not save icon cache: \(error.localizedDescription, privacy: .public)") + } + } + return image + } + + private func remember(_ image: CGImage, for key: Key) { + memory.setObject( + image, forKey: key.filename as NSString, cost: image.bytesPerRow * image.height) + } + + private func acquireDownload() async { + if activeDownloads < maximumConcurrentDownloads { + activeDownloads += 1 + } else { + await withCheckedContinuation { downloadWaiters.append($0) } + } + } + + private func releaseDownload() { + if downloadWaiters.isEmpty { + activeDownloads -= 1 + } else { + downloadWaiters.removeFirst().resume() + } + } + + private func touch(_ path: URL) { + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: path.path) + } + + private func trimDiskOnce() { + if !hasTrimmedDisk { + hasTrimmedDisk = true + trimDisk() + } + } + + private func trimDisk() { + let properties: Set = [ + .isRegularFileKey, .fileSizeKey, .contentModificationDateKey, + ] + guard + let paths = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: Array(properties), options: .skipsHiddenFiles) + else { return } + let entries = paths.compactMap { path -> (url: URL, size: Int, date: Date)? in + let name = path.deletingPathExtension().lastPathComponent + guard path.pathExtension == "png", name.count == 64, + name.allSatisfy({ "0123456789abcdef".contains($0) }), + let values = try? path.resourceValues(forKeys: properties), values.isRegularFile == true + else { return nil } + return (path, values.fileSize ?? 0, values.contentModificationDate ?? .distantPast) + }.sorted { + $0.date == $1.date ? $0.url.lastPathComponent < $1.url.lastPathComponent : $0.date < $1.date + } + var size = entries.reduce(0) { $0 + $1.size } + for entry in entries where size > diskLimit { + do { + try FileManager.default.removeItem(at: entry.url) + size -= entry.size + } catch { + Self.logger.error( + "Could not remove cached icon: \(error.localizedDescription, privacy: .public)") + } + } + } + + private static func decode(_ data: Data) -> CGImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 256, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceShouldAllowFloat: false, + ] + return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } + + enum Failure: Error { + case invalidResponse + case httpStatus(Int) + case invalidImage + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d26e5a..15f2493 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,14 @@ target_compile_options(SimStoreCatalogLoadingTest PRIVATE -parse-as-library) add_test(NAME catalog-loading COMMAND SimStoreCatalogLoadingTest) set_tests_properties(catalog-loading PROPERTIES TIMEOUT 15) +add_executable(SimStoreIconCacheTest IconCacheTests.swift + "${PROJECT_SOURCE_DIR}/src/IconCache.swift" + "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" +) +target_compile_options(SimStoreIconCacheTest PRIVATE -parse-as-library) +add_test(NAME icon-cache COMMAND SimStoreIconCacheTest) +set_tests_properties(icon-cache PROPERTIES TIMEOUT 20) + add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" "${PROJECT_SOURCE_DIR}/src/Command.swift" diff --git a/tests/IconCacheTests.swift b/tests/IconCacheTests.swift new file mode 100644 index 0000000..840cdba --- /dev/null +++ b/tests/IconCacheTests.swift @@ -0,0 +1,344 @@ +import CoreGraphics +import Foundation +import ImageIO + +@main +@MainActor +struct IconCacheTests { + static func main() async { + do { + try await memoryAndOfflineDisk() + try await versionsAndDownsampling() + try await failuresAndRecovery() + try await sharedRequestsAndCancellation() + try await downloadLimit() + try await diskEviction() + try await diskFailureAndOversizedEntry() + print( + "PASS: icon memory/disk caches, invalidation, decoding, shared requests, cancellation, concurrency, and eviction." + ) + } catch { + FileHandle.standardError.write( + Data("Icon cache test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func memoryAndOfflineDisk() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try png() + let probe = IconTransport(data: data) + let cache = IconCache(directory: directory, transport: { try await probe.send($0) }) + let id = key("example") + let first = try await cache.image(for: id) + try require( + first.width == 152 && first.height == 152, "Keep smaller images at their original resolution." + ) + let file = directory.appending(component: id.filename) + try require(try Data(contentsOf: file) == data, "Persist the original PNG bytes.") + try FileManager.default.removeItem(at: file) + let second = try await cache.image(for: id) + let calls = await probe.calls + try require( + first === second && calls.count == 1, + "Reuse the decoded image without another download.") + try data.write(to: file) + let offline = IconCache( + directory: directory, transport: { _ in throw URLError(.notConnectedToInternet) }) + let restored = try await offline.image(for: id) + try require(restored.width == 152, "A fresh cache instance must work offline from disk.") + try require( + calls.first?.cachePolicy == .reloadIgnoringLocalCacheData, + "Use the app-managed cache instead of HTTP cached responses.") + try require(calls.first?.timeoutInterval == 30, "Bound the icon request timeout.") + } + + static func versionsAndDownsampling() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let probe = IconTransport(data: try png(width: 512, height: 384)) + let cache = IconCache(directory: directory, transport: { try await probe.send($0) }) + let first = key("example") + let recipe = key("example", revision: "second-recipe") + let version = key("example", version: "2.0.0") + for id in [first, recipe, version] { + let image = try await cache.image(for: id) + try require( + image.width == 256 && image.height == 192, + "Downsample large images while preserving their aspect ratio.") + try require( + FileManager.default.fileExists(atPath: directory.appending(component: id.filename).path), + "Cache each build under its own key.") + } + try require( + await probe.calls.count == 3, + "A version or recipe change invalidates the icon even if the asset URL stays the same.") + try require( + Set([first.filename, recipe.filename, version.filename]).count == 3, + "Build identities must not collide.") + } + + static func failuresAndRecovery() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try png() + let probe = IconTransport(data: data, status: 503) + let cache = IconCache(directory: directory, transport: { try await probe.send($0) }) + let id = key("retry") + do { + _ = try await cache.image(for: id) + throw failure("An HTTP error must not be accepted as an icon.") + } catch IconCache.Failure.httpStatus(503) {} + try require( + !FileManager.default.fileExists(atPath: directory.appending(component: id.filename).path), + "Do not persist failed HTTP responses.") + await probe.configure(data: Data("not an image".utf8)) + do { + _ = try await cache.image(for: id) + throw failure("A successful HTTP response must still contain an image.") + } catch IconCache.Failure.invalidImage {} + await probe.configure(data: data) + _ = try await cache.image(for: id) + try require( + await probe.calls.count == 3, "Failed requests must release their slot and permit retries.") + + let corrupt = key("corrupt") + try Data("broken cache".utf8).write(to: directory.appending(component: corrupt.filename)) + _ = try await cache.image(for: corrupt) + try require( + try Data(contentsOf: directory.appending(component: corrupt.filename)) == data, + "Replace corrupt cached bytes after a successful download.") + let invalid = IconCache( + directory: directory, + transport: { request in + ( + data, + URLResponse( + url: request.url!, mimeType: "image/png", expectedContentLength: data.count, + textEncodingName: nil) + ) + }) + do { + _ = try await invalid.image(for: key("non-http")) + throw failure("Require an HTTP response.") + } catch IconCache.Failure.invalidResponse {} + } + + static func sharedRequestsAndCancellation() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let probe = IconTransport(data: try png(), suspended: true) + let cache = IconCache(directory: directory, transport: { try await probe.send($0) }) + let id = key("shared") + let first = Task { try await cache.image(for: id) } + try await eventually { await probe.calls.count == 1 } + var joined = false + let second = Task { + joined = true + return try await cache.image(for: id) + } + try await eventually { joined } + first.cancel() + await probe.releaseAll() + let image = try await second.value + do { + _ = try await first.value + throw failure("A canceled view must not apply the downloaded icon.") + } catch is CancellationError {} + let calls = await probe.calls.count + try require( + image.width == 152 && calls == 1, + "Canceling one caller must not cancel the shared request.") + let canceled = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await cache.image(for: key("already-canceled")) + } + do { + _ = try await canceled.value + throw failure("Do not start requests for already canceled callers.") + } catch is CancellationError {} + try require( + await probe.calls.count == 1, "An already canceled request must not reach the network.") + } + + static func downloadLimit() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let probe = IconTransport(data: try png(), suspended: true) + let cache = IconCache(directory: directory, transport: { try await probe.send($0) }) + let jobs = (0..<7).map { index in + Task { try await cache.image(for: key("queued-\(index)")) } + } + try await eventually { await probe.calls.count == 4 } + await probe.release(0) + try await eventually { await probe.calls.count == 5 } + try require(await probe.peakActive == 4, "Only four distinct icons may download concurrently.") + await probe.releaseAll() + for job in jobs { _ = try await job.value } + let calls = await probe.calls.count + let peakActive = await probe.peakActive + try require( + calls == 7 && peakActive == 4, + "Queued downloads must all complete within the concurrency limit.") + } + + static func diskEviction() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try png() + let probe = IconTransport(data: data) + let cache = IconCache( + directory: directory, diskLimit: data.count * 2, transport: { try await probe.send($0) }) + let a = key("a") + let b = key("b") + let c = key("c") + for id in [a, b] { _ = try await cache.image(for: id) } + for (id, age) in [(a, 1), (b, 2)] { + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: Double(age))], + ofItemAtPath: directory.appending(component: id.filename).path) + } + _ = try await cache.image(for: a) + _ = try await cache.image(for: c) + try require( + FileManager.default.fileExists(atPath: directory.appending(component: a.filename).path), + "A memory hit must refresh the icon's disk recency.") + try require( + !FileManager.default.fileExists(atPath: directory.appending(component: b.filename).path), + "Evict the least recently used icon first.") + try data.write(to: directory.appending(component: b.filename)) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1)], + ofItemAtPath: directory.appending(component: b.filename).path) + let unrelated = directory.appending(component: "notes.txt") + try Data("Keep this file".utf8).write(to: unrelated) + let offline = IconCache( + directory: directory, diskLimit: data.count * 2, + transport: { _ in throw URLError(.notConnectedToInternet) }) + _ = try await offline.image(for: c) + try require( + !FileManager.default.fileExists(atPath: directory.appending(component: b.filename).path), + "Trim leftover over-budget files on the first disk access.") + try require( + FileManager.default.fileExists(atPath: unrelated.path), + "Only manage files with generated cache names.") + } + + static func diskFailureAndOversizedEntry() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try png() + let blocked = directory.appending(component: "regular-file") + try Data().write(to: blocked) + for (path, limit) in [ + (blocked.appending(component: "icons"), data.count * 2), + (directory.appending(component: "tiny-cache"), 1), + ] { + let probe = IconTransport(data: data) + let cache = IconCache( + directory: path, diskLimit: limit, transport: { try await probe.send($0) }) + let id = key("no-disk") + let image = try await cache.image(for: id) + try require(image.width == 152, "A disk write failure must not hide a downloaded image.") + try require( + !FileManager.default.fileExists(atPath: path.appending(component: id.filename).path), + "Do not write entries larger than the entire disk budget.") + _ = try await cache.image(for: id) + try require( + await probe.calls.count == 1, "Keep using memory when persistence is unavailable.") + } + } + + static func key(_ id: String, version: String = "1.0.0", revision: String = "recipe") + -> IconCache.Key + { + IconCache.Key( + url: URL(string: "https://example.com/releases/download/\(id)%2F\(version)/icon.png")!, + recipeRevision: revision) + } + + static func png(width: Int = 152, height: Int = 152) throws -> Data { + guard + let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { throw failure("Cannot create an image fixture.") } + context.setFillColor(red: 0.2, green: 0.5, blue: 0.8, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let data = NSMutableData() + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithData(data, "public.png" as CFString, 1, nil) + else { throw failure("Cannot encode an image fixture.") } + CGImageDestinationAddImage(destination, image, nil) + try require(CGImageDestinationFinalize(destination), "Finish encoding the image fixture.") + return data as Data + } + + static func temporaryDirectory() throws -> URL { + let directory = URL.temporaryDirectory.appending( + component: "icon-cache-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func eventually(_ condition: () async -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !(await condition()) { + guard ContinuousClock.now < deadline else { throw failure("An async operation timed out.") } + try await Task.sleep(for: .milliseconds(1)) + } + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "IconCacheTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} + +private actor IconTransport { + private var data: Data + private var status: Int + private var suspended: Bool + private var pending: [Int: CheckedContinuation] = [:] + private var active = 0 + private(set) var peakActive = 0 + private(set) var calls: [URLRequest] = [] + + init(data: Data, status: Int = 200, suspended: Bool = false) { + self.data = data + self.status = status + self.suspended = suspended + } + + func send(_ request: URLRequest) async throws -> (Data, URLResponse) { + let index = calls.count + calls.append(request) + active += 1 + peakActive = max(peakActive, active) + defer { active -= 1 } + if suspended { await withCheckedContinuation { pending[index] = $0 } } + let response = HTTPURLResponse( + url: URL(string: "https://release-assets.example.com/signed-image")!, statusCode: status, + httpVersion: nil, headerFields: nil)! + return (data, response) + } + + func configure(data: Data, status: Int = 200) { + self.data = data + self.status = status + } + + func release(_ index: Int) { pending.removeValue(forKey: index)?.resume() } + + func releaseAll() { + suspended = false + let continuations = pending.values + pending.removeAll() + for continuation in continuations { continuation.resume() } + } +}