diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 3f62e0c..f166a32 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -10,7 +10,7 @@ import Foundation import OSLog @MainActor -final class SourceLibrary: ObservableObject, SourceScanSessionHosting { +final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 private static let minimumVisibleScanDuration: TimeInterval = 0.8 @@ -27,7 +27,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { @Published var sources: [MinecraftSource] = [] @Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = [] - @Published private(set) var isRestoringPersistedSources = true + @Published var isRestoringPersistedSources = true private var scanTasks: [URL: Task] = [:] private var automaticSyncTasks: [URL: Task] = [:] @@ -54,7 +54,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { self.notificationService = notificationService ?? ScanNotificationService.shared Task { [weak self] in - await self?.restorePersistedSources() + guard let self else { + return + } + await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore) } localSourceRefreshTask = Task { [weak self] in @@ -110,7 +113,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { return } - await persistVisibleSourcesForShutdown() + await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown( + from: visibleSources, + using: persistenceStore + ) shutdown() try? await Task.sleep(for: .seconds(timeout)) } @@ -194,7 +200,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { scanTasks[sourceID]?.cancel() scanTasks[sourceID] = nil sources.removeAll { $0.id == sourceID } - deletePersistedSource(withID: sourceID) + SourcePersistenceCoordinator.deletePersistedSource(withID: sourceID, using: persistenceStore) if let removedSource { purgeCachedArtifacts(for: removedSource) } @@ -249,7 +255,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { ) } - private func rebuildNormalizedIndex(for sourceID: URL) { + func rebuildNormalizedIndex(for sourceID: URL) { updateSource(sourceID) { source in let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems) source.rawItems = rawItems @@ -504,7 +510,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { } } - private func refreshLocalSources() async { + func refreshLocalSources() async { guard !hasActiveScan else { return } @@ -557,7 +563,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { } } - private func refreshConnectedDevices() async { + func refreshConnectedDevices() async { guard !isShuttingDown else { return } @@ -786,72 +792,35 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { } } - private func restorePersistedSources() async { - defer { - isRestoringPersistedSources = false - } - - let records: [PersistedSourceRecord] - do { - records = try await persistenceStore.loadSources() - } catch { - return - } - - for record in records { - let source = SourceRestoration.restoredSource(from: record) { [connectedDeviceSourceFactory] device, container in - connectedDeviceSourceFactory.displayName(for: device, container: container) - } - - sources.append(source) - rebuildNormalizedIndex(for: source.id) - } - - for record in records where record.needsRepair { - Task.detached(priority: .utility) { [persistenceStore] in - try? await persistenceStore.repair(record: record) - } - } - - sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } - await Task.yield() - - for record in records { - let restoredItems = await SourceRestoration.restoreCachedImages(in: record.rawItems) - updateSource(record.sourceID) { source in - SourceRestoration.applyRestoredItemState( - restoredItems, - lastScanDate: record.lastScanDate, - snapshot: record.snapshot, - to: &source - ) - } - rebuildNormalizedIndex(for: record.sourceID) - } - - await refreshConnectedDevices() - await refreshLocalSources() - scheduleRestoredSourceRefreshes(records: records) - } - - private func scheduleRestoredSourceRefreshes(records: [PersistedSourceRecord]) { - let persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.sourceID, $0) }) - - for source in sources { - if let refreshReason = SourceRestoration.startupRefreshReason( - for: source, - persistedRecord: persistedRecordsByID[source.id], - currentCollectionSnapshots: currentCollectionSnapshots(for:) - ) { - queueAutomaticSync(for: source.id, reason: refreshReason) - } - } - } - - private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { + func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { WorldScanner.collectionSnapshots(in: sourceURL) } + func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { + connectedDeviceSourceFactory.displayName(for: device, container: container) + } + + func appendRestoredSource(_ source: MinecraftSource) { + sources.append(source) + rebuildNormalizedIndex(for: source.id) + } + + func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord) { + updateSource(record.sourceID) { source in + SourceRestoration.applyRestoredItemState( + items, + lastScanDate: record.lastScanDate, + snapshot: record.snapshot, + to: &source + ) + } + rebuildNormalizedIndex(for: record.sourceID) + } + + func sortSourcesByDisplayName() { + sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + } + private func buildDisplayItems( from rawItems: [MinecraftContentItem], logicalPacks: [LogicalPack], @@ -891,30 +860,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting { } func persistSourceIfAvailable(withID sourceID: URL) { - guard let source = source(withID: sourceID) else { - return - } - - let persistedSource = source - Task { - try? await persistenceStore.save(source: persistedSource) - } + SourcePersistenceCoordinator.persistSourceIfAvailable( + withID: sourceID, + on: self, + using: persistenceStore + ) } - private func persistVisibleSourcesForShutdown() async { - let persistedSources = sources - for source in persistedSources { - try? await persistenceStore.save(source: source) - } - } - - private func deletePersistedSource(withID sourceID: URL) { - Task { - try? await persistenceStore.deleteSource(withID: sourceID) - } - } - - private func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) { + func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) { guard !isShuttingDown else { return } diff --git a/World Manager for Minecraft/Services/SourcePersistenceCoordinator.swift b/World Manager for Minecraft/Services/SourcePersistenceCoordinator.swift new file mode 100644 index 0000000..b7a20e8 --- /dev/null +++ b/World Manager for Minecraft/Services/SourcePersistenceCoordinator.swift @@ -0,0 +1,118 @@ +// +// SourcePersistenceCoordinator.swift +// World Manager for Minecraft +// +// Created by OpenAI Codex on 2026-05-29. +// + +import Foundation + +@MainActor +protocol SourcePersistenceHosting: AnyObject { + var visibleSources: [MinecraftSource] { get } + var isRestoringPersistedSources: Bool { get set } + + func source(withID sourceID: URL) -> MinecraftSource? + func appendRestoredSource(_ source: MinecraftSource) + func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord) + func sortSourcesByDisplayName() + func refreshConnectedDevices() async + func refreshLocalSources() async + func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?) + func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] + func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String +} + +enum SourcePersistenceCoordinator { + static func restoreSources( + on host: SourcePersistenceHosting, + using persistenceStore: SourcePersistenceStore + ) async { + defer { + host.isRestoringPersistedSources = false + } + + let records: [PersistedSourceRecord] + do { + records = try await persistenceStore.loadSources() + } catch { + return + } + + for record in records { + let source = SourceRestoration.restoredSource(from: record) { device, container in + host.connectedDeviceDisplayName(for: device, container: container) + } + + host.appendRestoredSource(source) + } + + for record in records where record.needsRepair { + Task.detached(priority: .utility) { + try? await persistenceStore.repair(record: record) + } + } + + host.sortSourcesByDisplayName() + await Task.yield() + + for record in records { + let restoredItems = await SourceRestoration.restoreCachedImages(in: record.rawItems) + host.applyRestoredItems(restoredItems, from: record) + } + + await host.refreshConnectedDevices() + await host.refreshLocalSources() + scheduleRestoredSourceRefreshes(records: records, on: host) + } + + static func persistSourceIfAvailable( + withID sourceID: URL, + on host: SourcePersistenceHosting, + using persistenceStore: SourcePersistenceStore + ) { + guard let source = host.source(withID: sourceID) else { + return + } + + let persistedSource = source + Task { + try? await persistenceStore.save(source: persistedSource) + } + } + + static func persistVisibleSourcesForShutdown( + from sources: [MinecraftSource], + using persistenceStore: SourcePersistenceStore + ) async { + for source in sources { + try? await persistenceStore.save(source: source) + } + } + + static func deletePersistedSource( + withID sourceID: URL, + using persistenceStore: SourcePersistenceStore + ) { + Task { + try? await persistenceStore.deleteSource(withID: sourceID) + } + } + + private static func scheduleRestoredSourceRefreshes( + records: [PersistedSourceRecord], + on host: SourcePersistenceHosting + ) { + let persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.sourceID, $0) }) + + for source in host.visibleSources { + if let refreshReason = SourceRestoration.startupRefreshReason( + for: source, + persistedRecord: persistedRecordsByID[source.id], + currentCollectionSnapshots: host.currentCollectionSnapshots(for:) + ) { + host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil) + } + } + } +}