diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 632e499..0e4306f 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, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting { +final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 private static let minimumVisibleScanDuration: TimeInterval = 0.8 @@ -206,7 +206,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } } - private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) { + func startScan(for sourceID: URL, mode: SourceDiscoveryMode) { guard !isShuttingDown else { return } @@ -416,53 +416,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) { - guard !isShuttingDown else { - return - } - - guard let source = source(withID: sourceID), source.availability == .available else { - return - } - - if source.isScanning { - return - } - - let resolvedDebounce = debounce ?? Self.automaticSyncDebounce - - automaticSyncTasks[sourceID]?.cancel() - updateSource(sourceID) { source in - guard !source.isScanning else { - return - } - - source.scanError = nil - if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { - source.scanDiagnostic = nil - } - source.scanStatus = reason - source.scanProgress = nil - } - - let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan - - let task = Task { [weak self] in - do { - try await Task.sleep(for: .seconds(resolvedDebounce)) - } catch { - return - } - - guard let self, !Task.isCancelled else { - return - } - - await MainActor.run { - self.startScan(for: sourceID, mode: mode) - } - } - - automaticSyncTasks[sourceID] = task + SourceSyncRuntime.queueAutomaticSync( + for: sourceID, + reason: reason, + debounce: debounce, + defaultDebounce: Self.automaticSyncDebounce, + on: self + ) } private func purgeCachedArtifacts(for source: MinecraftSource) { @@ -481,42 +441,15 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer @discardableResult func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) { - let previousAvailability = source(withID: sourceID)?.availability ?? .unknown - let becameAvailable = previousAvailability != .available && newAvailability == .available - - updateSource(sourceID) { source in - source.availability = newAvailability - - guard !source.isScanning else { - return - } - - if newAvailability == .available { - source.scanError = nil - if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { - source.scanDiagnostic = nil - } - if becameAvailable || source.scanStatus.isEmpty { - source.scanStatus = source.indexedItemCount == 0 - ? "No Minecraft items found." - : "Loaded \(source.indexedDetailCount) items." - } - } else { - source.scanError = nil - source.scanProgress = nil - source.scanStatus = source.availabilityDisplayText - source.scanDiagnostic = source.cachedAvailabilityDetailText - } - } - - return (previousAvailability, becameAvailable) + SourceSyncRuntime.updateAvailability(for: sourceID, to: newAvailability, on: self) } - private func isCachedAvailabilityDiagnostic(_ diagnostic: String?) -> Bool { - guard let diagnostic else { - return false - } + func cancelAutomaticSync(for sourceID: URL) { + automaticSyncTasks[sourceID]?.cancel() + automaticSyncTasks[sourceID] = nil + } - return diagnostic.localizedCaseInsensitiveContains("showing cached results") + func storeAutomaticSyncTask(_ task: Task, for sourceID: URL) { + automaticSyncTasks[sourceID] = task } } diff --git a/World Manager for Minecraft/Services/SourceSyncRuntime.swift b/World Manager for Minecraft/Services/SourceSyncRuntime.swift new file mode 100644 index 0000000..3aafe6e --- /dev/null +++ b/World Manager for Minecraft/Services/SourceSyncRuntime.swift @@ -0,0 +1,121 @@ +// +// SourceSyncRuntime.swift +// World Manager for Minecraft +// +// Created by OpenAI Codex on 2026-05-29. +// + +import Foundation + +@MainActor +protocol SourceSyncRuntimeHosting: AnyObject { + var isShuttingDown: Bool { get } + + func source(withID sourceID: URL) -> MinecraftSource? + func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) + func cancelAutomaticSync(for sourceID: URL) + func storeAutomaticSyncTask(_ task: Task, for sourceID: URL) + func startScan(for sourceID: URL, mode: SourceDiscoveryMode) +} + +enum SourceSyncRuntime { + static func queueAutomaticSync( + for sourceID: URL, + reason: String, + debounce: TimeInterval?, + defaultDebounce: TimeInterval, + on host: SourceSyncRuntimeHosting + ) { + guard !host.isShuttingDown else { + return + } + + guard let source = host.source(withID: sourceID), source.availability == .available else { + return + } + + guard !source.isScanning else { + return + } + + let resolvedDebounce = debounce ?? defaultDebounce + + host.cancelAutomaticSync(for: sourceID) + host.updateSource(sourceID) { source in + guard !source.isScanning else { + return + } + + source.scanError = nil + if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { + source.scanDiagnostic = nil + } + source.scanStatus = reason + source.scanProgress = nil + } + + let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan + let task = Task { [weak host] in + do { + try await Task.sleep(for: .seconds(resolvedDebounce)) + } catch { + return + } + + guard let host, !Task.isCancelled else { + return + } + + await MainActor.run { + host.startScan(for: sourceID, mode: mode) + } + } + + host.storeAutomaticSyncTask(task, for: sourceID) + } + + @discardableResult + static func updateAvailability( + for sourceID: URL, + to newAvailability: SourceAvailability, + on host: SourceSyncRuntimeHosting + ) -> (previous: SourceAvailability, becameAvailable: Bool) { + let previousAvailability = host.source(withID: sourceID)?.availability ?? .unknown + let becameAvailable = previousAvailability != .available && newAvailability == .available + + host.updateSource(sourceID) { source in + source.availability = newAvailability + + guard !source.isScanning else { + return + } + + if newAvailability == .available { + source.scanError = nil + if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { + source.scanDiagnostic = nil + } + if becameAvailable || source.scanStatus.isEmpty { + source.scanStatus = source.indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(source.indexedDetailCount) items." + } + } else { + source.scanError = nil + source.scanProgress = nil + source.scanStatus = source.availabilityDisplayText + source.scanDiagnostic = source.cachedAvailabilityDetailText + } + } + + return (previousAvailability, becameAvailable) + } + + private static func isCachedAvailabilityDiagnostic(_ diagnostic: String?) -> Bool { + guard let diagnostic else { + return false + } + + return diagnostic.localizedCaseInsensitiveContains("showing cached results") + } +}