world-manager/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift
2026-06-01 20:50:52 -05:00

200 lines
7.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
enum SourceScanPolicy {
static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan):
return "Preparing folder scan..."
case (.localFolder, .reconcile), (.javaLocalFolder, .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), (.javaLocalFolder, .fullScan):
return "Scanning Minecraft library..."
case (.localFolder, .reconcile), (.javaLocalFolder, .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, .javaLocalFolder:
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: [CollectionSnapshot]
switch source.edition {
case .bedrock:
collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
case .java:
collectionSnapshots = JavaContentScanner.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.displayItemCountsByType = previousSource.displayItemCountsByType
source.displayItemCountsByKind = previousSource.displayItemCountsByKind
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
}
}