Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Cache behavior

Both caches live under `URL.cachesDirectory/<application identifier>/`. 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/<key>.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.
1 change: 1 addition & 0 deletions src/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ add_executable(SimStore MACOSX_BUNDLE
CatalogClient.swift
CatalogStore.swift
CatalogPresentation.swift
IconCache.swift
CatalogBrowserView.swift
CatalogAppDetailView.swift
SidebarView.swift
Expand Down
3 changes: 1 addition & 2 deletions src/CatalogClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 21 additions & 6 deletions src/CatalogPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {}
}
}
}

Expand Down
201 changes: 201 additions & 0 deletions src/IconCache.swift
Original file line number Diff line number Diff line change
@@ -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<NSString, CGImage>()
private var requests: [Key: Task<CGImage, Error>] = [:]
private var activeDownloads = 0
private var downloadWaiters: [CheckedContinuation<Void, Never>] = []
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<URLResourceKey> = [
.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
}
}
8 changes: 8 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading