Image caching

This commit is contained in:
John Burwell 2026-05-26 06:49:10 -05:00
parent 516469427e
commit ef86972724
5 changed files with 281 additions and 25 deletions

View File

@ -91,6 +91,12 @@ struct ContentView: View {
) )
.frame(minWidth: 450) .frame(minWidth: 450)
} }
.overlay {
if library.isRestoringPersistedSources {
LaunchRestoreOverlayView()
}
}
.disabled(library.isRestoringPersistedSources)
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return return
@ -1315,6 +1321,33 @@ private struct SharingPickerButton: NSViewRepresentable {
} }
} }
private struct LaunchRestoreOverlayView: View {
var body: some View {
ZStack {
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea()
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
Text("Restoring Saved Library")
.font(.title3.weight(.semibold))
Text("Loading saved sources, metadata, and cached artwork.")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 32)
.padding(.vertical, 28)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(.background.opacity(0.92))
)
}
}
}
private struct PackReferenceIconView: View { private struct PackReferenceIconView: View {
let iconURL: URL? let iconURL: URL?

View File

@ -0,0 +1,97 @@
//
// ImageCacheStore.swift
// World Manager for Minecraft
//
// Created by OpenAI on 2026-05-26.
//
import CryptoKit
import Foundation
actor ImageCacheStore {
static let shared = ImageCacheStore()
private let fileManager: FileManager
private let cacheDirectoryURL: URL
private let cacheDirectoryPath: String
init(fileManager: FileManager = .default) {
self.fileManager = fileManager
let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Caches", isDirectory: true)
let cacheDirectoryURL = cachesURL
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
.appendingPathComponent("ImageCache", isDirectory: true)
self.cacheDirectoryURL = cacheDirectoryURL
self.cacheDirectoryPath = cacheDirectoryURL.standardizedFileURL.path
}
func cachedImageURL(for sourceImageURL: URL?) -> URL? {
guard let sourceImageURL else {
return nil
}
let normalizedSourceURL = sourceImageURL.standardizedFileURL
if isCachedImageURL(normalizedSourceURL) {
return fileManager.fileExists(atPath: normalizedSourceURL.path) ? normalizedSourceURL : nil
}
guard fileManager.fileExists(atPath: normalizedSourceURL.path) else {
return nil
}
guard
let resourceValues = try? normalizedSourceURL.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]),
let fileSize = resourceValues.fileSize
else {
return nil
}
let modifiedStamp = resourceValues.contentModificationDate?.timeIntervalSince1970 ?? 0
let sourceKey = digest(for: normalizedSourceURL.path)
let versionKey = digest(for: "\(normalizedSourceURL.path)|\(modifiedStamp)|\(fileSize)")
let pathExtension = normalizedSourceURL.pathExtension.isEmpty ? "img" : normalizedSourceURL.pathExtension
let cachedURL = cacheDirectoryURL
.appendingPathComponent("\(sourceKey)-\(versionKey)", isDirectory: false)
.appendingPathExtension(pathExtension)
do {
try fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true)
if fileManager.fileExists(atPath: cachedURL.path) {
return cachedURL
}
purgeStaleVariants(forSourceKey: sourceKey, keeping: cachedURL)
try fileManager.copyItem(at: normalizedSourceURL, to: cachedURL)
return cachedURL
} catch {
return nil
}
}
func isCachedImageURL(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(cacheDirectoryPath + "/")
}
private func purgeStaleVariants(forSourceKey sourceKey: String, keeping cachedURL: URL) {
guard let cachedFiles = try? fileManager.contentsOfDirectory(
at: cacheDirectoryURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else {
return
}
for url in cachedFiles where url.lastPathComponent.hasPrefix(sourceKey + "-") && url != cachedURL {
try? fileManager.removeItem(at: url)
}
}
private func digest(for value: String) -> String {
let bytes = SHA256.hash(data: Data(value.utf8))
return bytes.map { String(format: "%02x", $0) }.joined()
}
}

View File

@ -34,6 +34,7 @@ final class SourceLibrary: ObservableObject {
subtitle: nil, subtitle: nil,
revealURL: nil revealURL: nil
) )
@Published private(set) var isRestoringPersistedSources = true
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>? private var footerResetTask: Task<Void, Never>?
@ -168,6 +169,10 @@ final class SourceLibrary: ObservableObject {
let sizeQueue = EnrichmentWorkQueue() let sizeQueue = EnrichmentWorkQueue()
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
guard let library = self else {
return
}
while let item = await enrichmentQueue.next() { while let item = await enrichmentQueue.next() {
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
@ -176,12 +181,8 @@ final class SourceLibrary: ObservableObject {
let enrichedItem = await WorldScanner.enrich(item: item) let enrichedItem = await WorldScanner.enrich(item: item)
if let snapshot = await index.applyEnrichedItem(enrichedItem) { if let snapshot = await index.applyEnrichedItem(enrichedItem) {
await MainActor.run { await MainActor.run {
guard let self else { library.applySnapshot(snapshot, to: sourceID)
return library.refreshSidebarFooterState()
}
self.applySnapshot(snapshot, to: sourceID)
self.refreshSidebarFooterState()
} }
} }
await sizeQueue.enqueue(enrichedItem) await sizeQueue.enqueue(enrichedItem)
@ -190,6 +191,10 @@ final class SourceLibrary: ObservableObject {
} }
sizeWorkerTasks = (0..<Self.sizeWorkerCount).map { _ in sizeWorkerTasks = (0..<Self.sizeWorkerCount).map { _ in
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
guard let library = self else {
return
}
while let item = await sizeQueue.next() { while let item = await sizeQueue.next() {
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
@ -198,12 +203,8 @@ final class SourceLibrary: ObservableObject {
let sizedItem = WorldScanner.loadSize(for: item) let sizedItem = WorldScanner.loadSize(for: item)
if let snapshot = await index.applySizedItem(sizedItem) { if let snapshot = await index.applySizedItem(sizedItem) {
await MainActor.run { await MainActor.run {
guard let self else { library.applySnapshot(snapshot, to: sourceID)
return library.refreshSidebarFooterState()
}
self.applySnapshot(snapshot, to: sourceID)
self.refreshSidebarFooterState()
} }
} }
} }
@ -663,6 +664,11 @@ final class SourceLibrary: ObservableObject {
} }
private func restorePersistedSources() async { private func restorePersistedSources() async {
defer {
isRestoringPersistedSources = false
refreshSidebarFooterState()
}
let records: [PersistedSourceRecord] let records: [PersistedSourceRecord]
do { do {
records = try await persistenceStore.loadSources() records = try await persistenceStore.loadSources()
@ -673,9 +679,9 @@ final class SourceLibrary: ObservableObject {
for record in records { for record in records {
var source = MinecraftSource(folderURL: record.folderURL) var source = MinecraftSource(folderURL: record.folderURL)
source.displayName = record.displayName source.displayName = record.displayName
source.rawItems = record.rawItems source.rawItems = await restoreCachedImages(in: record.rawItems)
source.indexedItemCount = record.rawItems.count source.indexedItemCount = record.rawItems.count
source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count
source.lastScanDate = record.lastScanDate source.lastScanDate = record.lastScanDate
source.snapshot = record.snapshot source.snapshot = record.snapshot
@ -691,7 +697,6 @@ final class SourceLibrary: ObservableObject {
} }
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
refreshSidebarFooterState()
for record in records { for record in records {
if sourceNeedsRescan(record) { if sourceNeedsRescan(record) {
@ -700,6 +705,40 @@ final class SourceLibrary: ObservableObject {
} }
} }
private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] {
var restoredItems: [MinecraftContentItem] = []
restoredItems.reserveCapacity(items.count)
for var item in items {
item.iconURL = await ImageCacheStore.shared.cachedImageURL(for: item.iconURL)
item.packReferences = await restoreCachedImages(in: item.packReferences)
restoredItems.append(item)
}
return restoredItems
}
private func restoreCachedImages(in references: [ContentPackReference]) async -> [ContentPackReference] {
var restoredReferences: [ContentPackReference] = []
restoredReferences.reserveCapacity(references.count)
for reference in references {
let cachedIconURL = await ImageCacheStore.shared.cachedImageURL(for: reference.iconURL)
restoredReferences.append(
ContentPackReference(
name: reference.name,
type: reference.type,
iconURL: cachedIconURL,
uuid: reference.uuid,
version: reference.version,
source: reference.source
)
)
}
return restoredReferences
}
private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool { private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool {
guard let snapshot = record.snapshot else { guard let snapshot = record.snapshot else {
return true return true
@ -833,6 +872,17 @@ final class SourceLibrary: ObservableObject {
} }
private func refreshSidebarFooterState() { private func refreshSidebarFooterState() {
if isRestoringPersistedSources {
cancelFooterReset()
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: "Restoring library...",
subtitle: "Loading saved sources and cached metadata",
revealURL: nil
)
return
}
let scanningSources = sources.filter(\.isScanning) let scanningSources = sources.filter(\.isScanning)
if let source = scanningSources.first { if let source = scanningSources.first {
cancelFooterReset() cancelFooterReset()

View File

@ -16,7 +16,7 @@ struct PersistedSourceRecord: Sendable {
let lastScanDate: Date? let lastScanDate: Date?
} }
private struct PersistedItemSnapshotPayload: Codable { private struct PersistedItemSnapshotPayload: Codable, Sendable {
let idPath: String let idPath: String
let relativePath: String let relativePath: String
let modifiedDate: Date? let modifiedDate: Date?
@ -24,7 +24,7 @@ private struct PersistedItemSnapshotPayload: Codable {
let packUUID: String? let packUUID: String?
let packVersion: String? let packVersion: String?
init(_ snapshot: ItemSnapshot) { nonisolated init(_ snapshot: ItemSnapshot) {
self.idPath = snapshot.id.path self.idPath = snapshot.id.path
self.relativePath = snapshot.relativePath self.relativePath = snapshot.relativePath
self.modifiedDate = snapshot.modifiedDate self.modifiedDate = snapshot.modifiedDate
@ -33,7 +33,7 @@ private struct PersistedItemSnapshotPayload: Codable {
self.packVersion = snapshot.packVersion self.packVersion = snapshot.packVersion
} }
var itemSnapshot: ItemSnapshot { nonisolated var itemSnapshot: ItemSnapshot {
ItemSnapshot( ItemSnapshot(
id: URL(fileURLWithPath: idPath), id: URL(fileURLWithPath: idPath),
relativePath: relativePath, relativePath: relativePath,
@ -43,22 +43,51 @@ private struct PersistedItemSnapshotPayload: Codable {
packVersion: packVersion packVersion: packVersion
) )
} }
nonisolated init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.idPath = try container.decode(String.self, forKey: .idPath)
self.relativePath = try container.decode(String.self, forKey: .relativePath)
self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
self.sizeBytes = try container.decodeIfPresent(Int64.self, forKey: .sizeBytes)
self.packUUID = try container.decodeIfPresent(String.self, forKey: .packUUID)
self.packVersion = try container.decodeIfPresent(String.self, forKey: .packVersion)
}
nonisolated func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(idPath, forKey: .idPath)
try container.encode(relativePath, forKey: .relativePath)
try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
try container.encodeIfPresent(sizeBytes, forKey: .sizeBytes)
try container.encodeIfPresent(packUUID, forKey: .packUUID)
try container.encodeIfPresent(packVersion, forKey: .packVersion)
}
private enum CodingKeys: String, CodingKey {
case idPath
case relativePath
case modifiedDate
case sizeBytes
case packUUID
case packVersion
}
} }
private struct PersistedCollectionSnapshotPayload: Codable { private struct PersistedCollectionSnapshotPayload: Codable, Sendable {
let folderName: String let folderName: String
let modifiedDate: Date? let modifiedDate: Date?
let childDirectoryCount: Int let childDirectoryCount: Int
let fingerprint: String let fingerprint: String
init(_ snapshot: CollectionSnapshot) { nonisolated init(_ snapshot: CollectionSnapshot) {
self.folderName = snapshot.folderName self.folderName = snapshot.folderName
self.modifiedDate = snapshot.modifiedDate self.modifiedDate = snapshot.modifiedDate
self.childDirectoryCount = snapshot.childDirectoryCount self.childDirectoryCount = snapshot.childDirectoryCount
self.fingerprint = snapshot.fingerprint self.fingerprint = snapshot.fingerprint
} }
var collectionSnapshot: CollectionSnapshot { nonisolated var collectionSnapshot: CollectionSnapshot {
CollectionSnapshot( CollectionSnapshot(
folderName: folderName, folderName: folderName,
modifiedDate: modifiedDate, modifiedDate: modifiedDate,
@ -66,22 +95,45 @@ private struct PersistedCollectionSnapshotPayload: Codable {
fingerprint: fingerprint fingerprint: fingerprint
) )
} }
nonisolated init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.folderName = try container.decode(String.self, forKey: .folderName)
self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
self.childDirectoryCount = try container.decode(Int.self, forKey: .childDirectoryCount)
self.fingerprint = try container.decode(String.self, forKey: .fingerprint)
}
nonisolated func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(folderName, forKey: .folderName)
try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
try container.encode(childDirectoryCount, forKey: .childDirectoryCount)
try container.encode(fingerprint, forKey: .fingerprint)
}
private enum CodingKeys: String, CodingKey {
case folderName
case modifiedDate
case childDirectoryCount
case fingerprint
}
} }
private struct PersistedSourceSnapshotPayload: Codable { private struct PersistedSourceSnapshotPayload: Codable, Sendable {
let sourcePath: String let sourcePath: String
let rootModifiedDate: Date? let rootModifiedDate: Date?
let collectionSnapshots: [PersistedCollectionSnapshotPayload] let collectionSnapshots: [PersistedCollectionSnapshotPayload]
let itemSnapshots: [PersistedItemSnapshotPayload] let itemSnapshots: [PersistedItemSnapshotPayload]
init(_ snapshot: SourceSnapshot) { nonisolated init(_ snapshot: SourceSnapshot) {
self.sourcePath = snapshot.sourceID.path self.sourcePath = snapshot.sourceID.path
self.rootModifiedDate = snapshot.rootModifiedDate self.rootModifiedDate = snapshot.rootModifiedDate
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init) self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init) self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
} }
var sourceSnapshot: SourceSnapshot { nonisolated var sourceSnapshot: SourceSnapshot {
SourceSnapshot( SourceSnapshot(
sourceID: URL(fileURLWithPath: sourcePath), sourceID: URL(fileURLWithPath: sourcePath),
rootModifiedDate: rootModifiedDate, rootModifiedDate: rootModifiedDate,
@ -89,6 +141,29 @@ private struct PersistedSourceSnapshotPayload: Codable {
itemSnapshots: itemSnapshots.map(\.itemSnapshot) itemSnapshots: itemSnapshots.map(\.itemSnapshot)
) )
} }
nonisolated init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.sourcePath = try container.decode(String.self, forKey: .sourcePath)
self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
}
nonisolated func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(sourcePath, forKey: .sourcePath)
try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
try container.encode(itemSnapshots, forKey: .itemSnapshots)
}
private enum CodingKeys: String, CodingKey {
case sourcePath
case rootModifiedDate
case collectionSnapshots
case itemSnapshots
}
} }
actor SourcePersistenceStore { actor SourcePersistenceStore {

View File

@ -91,7 +91,8 @@ enum WorldScanner {
var enrichedItem = item var enrichedItem = item
enrichedItem.displayName = displayName(for: item, fileManager: fileManager) enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager) let sourceIconURL = iconURL(for: item, fileManager: fileManager)
enrichedItem.iconURL = await ImageCacheStore.shared.cachedImageURL(for: sourceIconURL)
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager) enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) { if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {