Image caching
This commit is contained in:
parent
516469427e
commit
ef86972724
@ -91,6 +91,12 @@ struct ContentView: View {
|
||||
)
|
||||
.frame(minWidth: 450)
|
||||
}
|
||||
.overlay {
|
||||
if library.isRestoringPersistedSources {
|
||||
LaunchRestoreOverlayView()
|
||||
}
|
||||
}
|
||||
.disabled(library.isRestoringPersistedSources)
|
||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||
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 {
|
||||
let iconURL: URL?
|
||||
|
||||
|
||||
97
World Manager for Minecraft/Services/ImageCacheStore.swift
Normal file
97
World Manager for Minecraft/Services/ImageCacheStore.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,7 @@ final class SourceLibrary: ObservableObject {
|
||||
subtitle: nil,
|
||||
revealURL: nil
|
||||
)
|
||||
@Published private(set) var isRestoringPersistedSources = true
|
||||
|
||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||
private var footerResetTask: Task<Void, Never>?
|
||||
@ -168,6 +169,10 @@ final class SourceLibrary: ObservableObject {
|
||||
let sizeQueue = EnrichmentWorkQueue()
|
||||
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let library = self else {
|
||||
return
|
||||
}
|
||||
|
||||
while let item = await enrichmentQueue.next() {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
@ -176,12 +181,8 @@ final class SourceLibrary: ObservableObject {
|
||||
let enrichedItem = await WorldScanner.enrich(item: item)
|
||||
if let snapshot = await index.applyEnrichedItem(enrichedItem) {
|
||||
await MainActor.run {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.applySnapshot(snapshot, to: sourceID)
|
||||
self.refreshSidebarFooterState()
|
||||
library.applySnapshot(snapshot, to: sourceID)
|
||||
library.refreshSidebarFooterState()
|
||||
}
|
||||
}
|
||||
await sizeQueue.enqueue(enrichedItem)
|
||||
@ -190,6 +191,10 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
sizeWorkerTasks = (0..<Self.sizeWorkerCount).map { _ in
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let library = self else {
|
||||
return
|
||||
}
|
||||
|
||||
while let item = await sizeQueue.next() {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
@ -198,12 +203,8 @@ final class SourceLibrary: ObservableObject {
|
||||
let sizedItem = WorldScanner.loadSize(for: item)
|
||||
if let snapshot = await index.applySizedItem(sizedItem) {
|
||||
await MainActor.run {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.applySnapshot(snapshot, to: sourceID)
|
||||
self.refreshSidebarFooterState()
|
||||
library.applySnapshot(snapshot, to: sourceID)
|
||||
library.refreshSidebarFooterState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -663,6 +664,11 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func restorePersistedSources() async {
|
||||
defer {
|
||||
isRestoringPersistedSources = false
|
||||
refreshSidebarFooterState()
|
||||
}
|
||||
|
||||
let records: [PersistedSourceRecord]
|
||||
do {
|
||||
records = try await persistenceStore.loadSources()
|
||||
@ -673,9 +679,9 @@ final class SourceLibrary: ObservableObject {
|
||||
for record in records {
|
||||
var source = MinecraftSource(folderURL: record.folderURL)
|
||||
source.displayName = record.displayName
|
||||
source.rawItems = record.rawItems
|
||||
source.rawItems = await restoreCachedImages(in: record.rawItems)
|
||||
source.indexedItemCount = record.rawItems.count
|
||||
source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count
|
||||
source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count
|
||||
source.lastScanDate = record.lastScanDate
|
||||
source.snapshot = record.snapshot
|
||||
|
||||
@ -691,7 +697,6 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||
refreshSidebarFooterState()
|
||||
|
||||
for record in records {
|
||||
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 {
|
||||
guard let snapshot = record.snapshot else {
|
||||
return true
|
||||
@ -833,6 +872,17 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
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)
|
||||
if let source = scanningSources.first {
|
||||
cancelFooterReset()
|
||||
|
||||
@ -16,7 +16,7 @@ struct PersistedSourceRecord: Sendable {
|
||||
let lastScanDate: Date?
|
||||
}
|
||||
|
||||
private struct PersistedItemSnapshotPayload: Codable {
|
||||
private struct PersistedItemSnapshotPayload: Codable, Sendable {
|
||||
let idPath: String
|
||||
let relativePath: String
|
||||
let modifiedDate: Date?
|
||||
@ -24,7 +24,7 @@ private struct PersistedItemSnapshotPayload: Codable {
|
||||
let packUUID: String?
|
||||
let packVersion: String?
|
||||
|
||||
init(_ snapshot: ItemSnapshot) {
|
||||
nonisolated init(_ snapshot: ItemSnapshot) {
|
||||
self.idPath = snapshot.id.path
|
||||
self.relativePath = snapshot.relativePath
|
||||
self.modifiedDate = snapshot.modifiedDate
|
||||
@ -33,7 +33,7 @@ private struct PersistedItemSnapshotPayload: Codable {
|
||||
self.packVersion = snapshot.packVersion
|
||||
}
|
||||
|
||||
var itemSnapshot: ItemSnapshot {
|
||||
nonisolated var itemSnapshot: ItemSnapshot {
|
||||
ItemSnapshot(
|
||||
id: URL(fileURLWithPath: idPath),
|
||||
relativePath: relativePath,
|
||||
@ -43,22 +43,51 @@ private struct PersistedItemSnapshotPayload: Codable {
|
||||
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 modifiedDate: Date?
|
||||
let childDirectoryCount: Int
|
||||
let fingerprint: String
|
||||
|
||||
init(_ snapshot: CollectionSnapshot) {
|
||||
nonisolated init(_ snapshot: CollectionSnapshot) {
|
||||
self.folderName = snapshot.folderName
|
||||
self.modifiedDate = snapshot.modifiedDate
|
||||
self.childDirectoryCount = snapshot.childDirectoryCount
|
||||
self.fingerprint = snapshot.fingerprint
|
||||
}
|
||||
|
||||
var collectionSnapshot: CollectionSnapshot {
|
||||
nonisolated var collectionSnapshot: CollectionSnapshot {
|
||||
CollectionSnapshot(
|
||||
folderName: folderName,
|
||||
modifiedDate: modifiedDate,
|
||||
@ -66,22 +95,45 @@ private struct PersistedCollectionSnapshotPayload: Codable {
|
||||
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 rootModifiedDate: Date?
|
||||
let collectionSnapshots: [PersistedCollectionSnapshotPayload]
|
||||
let itemSnapshots: [PersistedItemSnapshotPayload]
|
||||
|
||||
init(_ snapshot: SourceSnapshot) {
|
||||
nonisolated init(_ snapshot: SourceSnapshot) {
|
||||
self.sourcePath = snapshot.sourceID.path
|
||||
self.rootModifiedDate = snapshot.rootModifiedDate
|
||||
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
|
||||
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
|
||||
}
|
||||
|
||||
var sourceSnapshot: SourceSnapshot {
|
||||
nonisolated var sourceSnapshot: SourceSnapshot {
|
||||
SourceSnapshot(
|
||||
sourceID: URL(fileURLWithPath: sourcePath),
|
||||
rootModifiedDate: rootModifiedDate,
|
||||
@ -89,6 +141,29 @@ private struct PersistedSourceSnapshotPayload: Codable {
|
||||
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 {
|
||||
|
||||
@ -91,7 +91,8 @@ enum WorldScanner {
|
||||
var enrichedItem = item
|
||||
|
||||
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.modifiedDate = modifiedDate(for: item.folderURL)
|
||||
if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user