Extract source scan policy helpers

This commit is contained in:
John Burwell 2026-05-28 21:02:22 -05:00
parent c7659ccda3
commit 1e0447a2b1
2 changed files with 206 additions and 201 deletions

View File

@ -269,13 +269,13 @@ final class SourceLibrary: ObservableObject {
return return
} }
let previousSource = source let previousSource = source
let performanceContext = performanceContext(for: source) let performanceContext = SourceScanPolicy.performanceContext(for: source)
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.isScanning = true source.isScanning = true
source.scanError = nil source.scanError = nil
source.scanDiagnostic = nil source.scanDiagnostic = nil
source.scanStatus = initialScanStatus(for: source, mode: mode) source.scanStatus = SourceScanPolicy.initialStatus(for: source, mode: mode)
source.scanProgress = nil source.scanProgress = nil
source.indexedItemCount = 0 source.indexedItemCount = 0
source.indexedDetailCount = 0 source.indexedDetailCount = 0
@ -301,7 +301,7 @@ final class SourceLibrary: ObservableObject {
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.availability = .available source.availability = .available
source.scanStatus = scanningLibraryStatus(for: source, mode: mode) source.scanStatus = SourceScanPolicy.scanningLibraryStatus(for: source, mode: mode)
} }
do { do {
@ -367,7 +367,7 @@ final class SourceLibrary: ObservableObject {
let itemForIndex: MinecraftContentItem let itemForIndex: MinecraftContentItem
if shouldReconcileFromCache, if shouldReconcileFromCache,
let cachedItem = previousItemsByID[item.id], let cachedItem = previousItemsByID[item.id],
shouldReuseCachedItem( SourceScanPolicy.shouldReuseCachedItem(
cachedItem, cachedItem,
forDiscoveredItem: item, forDiscoveredItem: item,
source: source, source: source,
@ -500,7 +500,7 @@ final class SourceLibrary: ObservableObject {
} }
updateSource(sourceID) { source in updateSource(sourceID) { source in
if source.origin.kind == .localFolder { if source.origin.kind == .localFolder {
source.snapshot = buildSnapshot(for: source, scanRootURL: scanContextURL, packMetadataByItemID: [:]) source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: scanContextURL)
} else { } else {
source.snapshot = nil source.snapshot = nil
} }
@ -572,7 +572,7 @@ final class SourceLibrary: ObservableObject {
} }
updateSource(sourceID) { source in updateSource(sourceID) { source in
if source.origin.kind == .localFolder { if source.origin.kind == .localFolder {
source.snapshot = buildSnapshot(for: source, scanRootURL: scanContextURL, packMetadataByItemID: [:]) source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: scanContextURL)
} else { } else {
source.snapshot = nil source.snapshot = nil
} }
@ -593,7 +593,7 @@ final class SourceLibrary: ObservableObject {
} }
} catch { } catch {
updateSource(sourceID) { source in updateSource(sourceID) { source in
if shouldPreservePartialScanContent(currentSource: source, previousSource: previousSource) { if SourceScanRecovery.shouldPreservePartialResults(currentSource: source, previousSource: previousSource) {
source.scanStatus = source.indexedItemCount == 0 source.scanStatus = source.indexedItemCount == 0
? previousSource.scanStatus ? previousSource.scanStatus
: "Loaded \(source.indexedDetailCount) items." : "Loaded \(source.indexedDetailCount) items."
@ -601,21 +601,17 @@ final class SourceLibrary: ObservableObject {
? "Showing the most recent partial scan results." ? "Showing the most recent partial scan results."
: "Showing the most recent partial scan results after the scan stopped early." : "Showing the most recent partial scan results after the scan stopped early."
if source.origin.kind == .localFolder, !source.rawItems.isEmpty { if source.origin.kind == .localFolder, !source.rawItems.isEmpty {
source.snapshot = buildSnapshot( source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: scanContextURL)
for: source,
scanRootURL: scanContextURL,
packMetadataByItemID: [:]
)
} }
} else { } else {
restoreScannedContent(from: previousSource, into: &source) SourceScanRecovery.restoreIndexedState(from: previousSource, into: &source)
} }
source.availability = Task.isCancelled source.availability = Task.isCancelled
? previousSource.availability ? previousSource.availability
: availabilityStatus(for: error, defaultingTo: previousSource.availability) : SourceScanPolicy.availabilityStatus(for: error, defaultingTo: previousSource.availability)
source.scanError = Task.isCancelled source.scanError = Task.isCancelled
? previousSource.scanError ? previousSource.scanError
: friendlyScanError(for: error, source: source) : SourceScanPolicy.friendlyError(for: error, source: source)
source.scanDiagnostic = Task.isCancelled source.scanDiagnostic = Task.isCancelled
? source.scanDiagnostic ? source.scanDiagnostic
: (source.scanDiagnostic ?? error.localizedDescription) : (source.scanDiagnostic ?? error.localizedDescription)
@ -779,55 +775,6 @@ final class SourceLibrary: ObservableObject {
} }
} }
private func restoreScannedContent(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
source.displayItems = previousSource.displayItems
source.rawItems = previousSource.rawItems
source.logicalPacks = previousSource.logicalPacks
source.logicalWorlds = previousSource.logicalWorlds
source.packInstances = previousSource.packInstances
source.worldPackRelationships = previousSource.worldPackRelationships
source.snapshot = previousSource.snapshot
source.indexedItemCount = previousSource.indexedItemCount
source.indexedDetailCount = previousSource.indexedDetailCount
source.previewLoadedCount = previousSource.previewLoadedCount
source.sizeLoadedCount = previousSource.sizeLoadedCount
source.scanProgress = previousSource.scanProgress
source.lastScanDate = previousSource.lastScanDate
}
private func shouldPreservePartialScanContent(
currentSource: MinecraftSource,
previousSource: MinecraftSource
) -> Bool {
if currentSource.rawItems.count > previousSource.rawItems.count {
return true
}
if currentSource.indexedDetailCount > previousSource.indexedDetailCount {
return true
}
if currentSource.previewLoadedCount > previousSource.previewLoadedCount {
return true
}
if currentSource.sizeLoadedCount > previousSource.sizeLoadedCount {
return true
}
return false
}
private func performanceContext(for source: MinecraftSource) -> String {
switch source.origin {
case .localFolder:
return "source=\(source.displayName) kind=local"
case .connectedDevice(let device, let container):
let transport = device.connection == .usb ? "usb" : "network"
return "source=\(source.displayName) kind=connected-device transport=\(transport) udid=\(device.udid) app=\(container.appID)"
}
}
private func logScanStage( private func logScanStage(
_ stage: String, _ stage: String,
elapsed: TimeInterval, elapsed: TimeInterval,
@ -853,33 +800,6 @@ final class SourceLibrary: ObservableObject {
) )
} }
private func friendlyScanError(for error: Error, source: MinecraftSource) -> String {
let description = error.localizedDescription
guard source.origin.kind == .connectedDevice else {
return "Failed to scan folder: \(description)"
}
if description.contains("AMDeviceCreateHouseArrestService returned -402653093")
|| description.contains("kAMDServiceLimitError") {
return "Device is busy. Too many device access sessions were open, so the scan could not start."
}
if description.localizedCaseInsensitiveContains("InstallationLookupFailed") {
return "The device refused access to the Minecraft app container."
}
if description.localizedCaseInsensitiveContains("not paired") {
return "The device is not paired with this Mac."
}
if description.localizedCaseInsensitiveContains("no longer available") {
return "The device disconnected during the scan."
}
return "Failed to scan device library."
}
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) { private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else { guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
return return
@ -1780,116 +1700,6 @@ final class SourceLibrary: ObservableObject {
return source.previewLoadedCount < itemCount || source.sizeLoadedCount < itemCount return source.previewLoadedCount < itemCount || source.sizeLoadedCount < itemCount
} }
private func initialScanStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
return "Preparing folder scan..."
case (.localFolder, .reconcile):
return "Preparing cached library refresh..."
case (.connectedDevice, .fullScan):
return "Connecting to device and discovering Minecraft items..."
case (.connectedDevice, .reconcile):
return "Connecting to device and refreshing cached library..."
}
}
private func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
return "Scanning Minecraft library..."
case (.localFolder, .reconcile):
return "Reconciling cached library..."
case (.connectedDevice, .fullScan):
return "Scanning Minecraft library on device..."
case (.connectedDevice, .reconcile):
return "Reconciling cached device library..."
}
}
private func shouldReuseCachedItem(
_ cachedItem: MinecraftContentItem,
forDiscoveredItem discoveredItem: MinecraftContentItem,
source: MinecraftSource,
previousSnapshot: ItemSnapshot?
) -> Bool {
guard cachedItem.contentType == discoveredItem.contentType else {
return false
}
guard cachedItem.metadataLoaded, cachedItem.previewLoaded, cachedItem.sizeLoaded else {
return false
}
switch source.origin.kind {
case .localFolder:
guard let previousSnapshot else {
return false
}
let currentModifiedDate = try? discoveredItem.folderURL
.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
return previousSnapshot.modifiedDate == currentModifiedDate
case .connectedDevice:
return cachedItem.folderName == discoveredItem.folderName
&& cachedItem.displayName == discoveredItem.displayName
&& cachedItem.hasKnownIcon == discoveredItem.hasKnownIcon
&& cachedItem.packUUID == discoveredItem.packUUID
&& cachedItem.packVersion == discoveredItem.packVersion
&& cachedItem.packMetadataDetails == discoveredItem.packMetadataDetails
&& cachedItem.packReferences == discoveredItem.packReferences
}
}
private func buildSnapshot(
for source: MinecraftSource,
scanRootURL: URL,
packMetadataByItemID: [URL: PackMetadata]
) -> SourceSnapshot {
let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
let itemSnapshots = source.rawItems.map { item in
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
let metadata = packMetadataByItemID[item.id]
return ItemSnapshot(
id: item.id,
relativePath: relativePath,
modifiedDate: item.modifiedDate,
sizeBytes: item.sizeBytes,
packUUID: metadata?.uuid,
packVersion: metadata?.version
)
}.sorted { (lhs: ItemSnapshot, rhs: ItemSnapshot) in
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
}
let rootModifiedDate = try? scanRootURL
.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
return SourceSnapshot(
sourceID: source.id,
rootModifiedDate: rootModifiedDate,
collectionSnapshots: collectionSnapshots,
itemSnapshots: itemSnapshots
)
}
private func availabilityStatus(for error: Error, defaultingTo currentAvailability: SourceAvailability) -> SourceAvailability {
if let accessError = error as? SourceAccessError {
switch accessError {
case .deviceUnavailable:
return .disconnected
case .deviceNotTrusted:
return .limited
case .appNotAccessible, .minecraftFolderMissing, .accessFailed:
return .unavailable
}
}
return currentAvailability
}
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
let candidateEmbedded = isEmbeddedWorldPack(candidate) let candidateEmbedded = isEmbeddedWorldPack(candidate)
let existingEmbedded = isEmbeddedWorldPack(existing) let existingEmbedded = isEmbeddedWorldPack(existing)

View File

@ -0,0 +1,195 @@
//
// SourceScanning.swift
// World Manager for Minecraft
//
// Created by OpenAI on 2026-05-28.
//
import Foundation
enum SourceScanPolicy {
static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
return "Preparing folder scan..."
case (.localFolder, .reconcile):
return "Preparing cached library refresh..."
case (.connectedDevice, .fullScan):
return "Connecting to device and discovering Minecraft items..."
case (.connectedDevice, .reconcile):
return "Connecting to device and refreshing cached library..."
}
}
static func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
return "Scanning Minecraft library..."
case (.localFolder, .reconcile):
return "Reconciling cached library..."
case (.connectedDevice, .fullScan):
return "Scanning Minecraft library on device..."
case (.connectedDevice, .reconcile):
return "Reconciling cached device library..."
}
}
static func performanceContext(for source: MinecraftSource) -> String {
switch source.origin {
case .localFolder:
return "source=\(source.displayName) kind=local"
case .connectedDevice(let device, let container):
let transport = device.connection == .usb ? "usb" : "network"
return "source=\(source.displayName) kind=connected-device transport=\(transport) udid=\(device.udid) app=\(container.appID)"
}
}
static func friendlyError(for error: Error, source: MinecraftSource) -> String {
let description = error.localizedDescription
guard source.origin.kind == .connectedDevice else {
return "Failed to scan folder: \(description)"
}
if description.contains("AMDeviceCreateHouseArrestService returned -402653093")
|| description.contains("kAMDServiceLimitError") {
return "Device is busy. Too many device access sessions were open, so the scan could not start."
}
if description.localizedCaseInsensitiveContains("InstallationLookupFailed") {
return "The device refused access to the Minecraft app container."
}
if description.localizedCaseInsensitiveContains("not paired") {
return "The device is not paired with this Mac."
}
if description.localizedCaseInsensitiveContains("no longer available") {
return "The device disconnected during the scan."
}
return "Failed to scan device library."
}
static func availabilityStatus(
for error: Error,
defaultingTo currentAvailability: SourceAvailability
) -> SourceAvailability {
if let accessError = error as? SourceAccessError {
switch accessError {
case .deviceUnavailable:
return .disconnected
case .deviceNotTrusted:
return .limited
case .appNotAccessible, .minecraftFolderMissing, .accessFailed:
return .unavailable
}
}
return currentAvailability
}
static func shouldReuseCachedItem(
_ cachedItem: MinecraftContentItem,
forDiscoveredItem discoveredItem: MinecraftContentItem,
source: MinecraftSource,
previousSnapshot: ItemSnapshot?
) -> Bool {
guard cachedItem.contentType == discoveredItem.contentType else {
return false
}
guard cachedItem.metadataLoaded, cachedItem.previewLoaded, cachedItem.sizeLoaded else {
return false
}
switch source.origin.kind {
case .localFolder:
guard let previousSnapshot else {
return false
}
let currentModifiedDate = try? discoveredItem.folderURL
.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
return previousSnapshot.modifiedDate == currentModifiedDate
case .connectedDevice:
return cachedItem.folderName == discoveredItem.folderName
&& cachedItem.displayName == discoveredItem.displayName
&& cachedItem.hasKnownIcon == discoveredItem.hasKnownIcon
&& cachedItem.packUUID == discoveredItem.packUUID
&& cachedItem.packVersion == discoveredItem.packVersion
&& cachedItem.packMetadataDetails == discoveredItem.packMetadataDetails
&& cachedItem.packReferences == discoveredItem.packReferences
}
}
static func buildSnapshot(for source: MinecraftSource, scanRootURL: URL) -> SourceSnapshot {
let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
let itemSnapshots = source.rawItems.map { item in
ItemSnapshot(
id: item.id,
relativePath: item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: ""),
modifiedDate: item.modifiedDate,
sizeBytes: item.sizeBytes,
packUUID: nil,
packVersion: nil
)
}.sorted { lhs, rhs in
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
}
let rootModifiedDate = try? scanRootURL
.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
return SourceSnapshot(
sourceID: source.id,
rootModifiedDate: rootModifiedDate,
collectionSnapshots: collectionSnapshots,
itemSnapshots: itemSnapshots
)
}
}
enum SourceScanRecovery {
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
source.displayItems = previousSource.displayItems
source.rawItems = previousSource.rawItems
source.logicalPacks = previousSource.logicalPacks
source.logicalWorlds = previousSource.logicalWorlds
source.packInstances = previousSource.packInstances
source.worldPackRelationships = previousSource.worldPackRelationships
source.snapshot = previousSource.snapshot
source.indexedItemCount = previousSource.indexedItemCount
source.indexedDetailCount = previousSource.indexedDetailCount
source.previewLoadedCount = previousSource.previewLoadedCount
source.sizeLoadedCount = previousSource.sizeLoadedCount
source.scanProgress = previousSource.scanProgress
source.lastScanDate = previousSource.lastScanDate
}
static func shouldPreservePartialResults(
currentSource: MinecraftSource,
previousSource: MinecraftSource
) -> Bool {
if currentSource.rawItems.count > previousSource.rawItems.count {
return true
}
if currentSource.indexedDetailCount > previousSource.indexedDetailCount {
return true
}
if currentSource.previewLoadedCount > previousSource.previewLoadedCount {
return true
}
if currentSource.sizeLoadedCount > previousSource.sizeLoadedCount {
return true
}
return false
}
}