diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 479e95e..12311e1 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -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? diff --git a/World Manager for Minecraft/Services/ImageCacheStore.swift b/World Manager for Minecraft/Services/ImageCacheStore.swift new file mode 100644 index 0000000..c0a073a --- /dev/null +++ b/World Manager for Minecraft/Services/ImageCacheStore.swift @@ -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() + } +} diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index baf7192..991e9e5 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -34,6 +34,7 @@ final class SourceLibrary: ObservableObject { subtitle: nil, revealURL: nil ) + @Published private(set) var isRestoringPersistedSources = true private var scanTasks: [URL: Task] = [:] private var footerResetTask: Task? @@ -168,6 +169,10 @@ final class SourceLibrary: ObservableObject { let sizeQueue = EnrichmentWorkQueue() workerTasks = (0.. [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() diff --git a/World Manager for Minecraft/Services/SourcePersistenceStore.swift b/World Manager for Minecraft/Services/SourcePersistenceStore.swift index 950a45c..a56b0b8 100644 --- a/World Manager for Minecraft/Services/SourcePersistenceStore.swift +++ b/World Manager for Minecraft/Services/SourcePersistenceStore.swift @@ -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 { diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index 5734ad1..dda0a7a 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -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) {