// // WorldScanner.swift // World Manager for Minecraft // // Created by John Burwell on 2026-05-25. // import Combine import Foundation @MainActor final class WorldScanner: ObservableObject { @Published var items: [MinecraftContentItem] = [] @Published var isScanning = false @Published var scanStatus = "" @Published var scanError: String? private var activeScanID = UUID() func scan(at searchRootURL: URL) async { let scanID = UUID() activeScanID = scanID isScanning = true scanError = nil scanStatus = "Searching for Minecraft content..." items = [] do { let discoveredItems = try await Task.detached(priority: .userInitiated) { try Self.discoverItems(in: searchRootURL) }.value guard activeScanID == scanID else { return } items = discoveredItems scanStatus = discoveredItems.isEmpty ? "No Minecraft content found." : "Found \(discoveredItems.count) items. Loading details..." var loadedCount = 0 await withTaskGroup(of: MinecraftContentItem.self) { group in for item in discoveredItems { group.addTask { Self.enrich(item: item) } } for await enrichedItem in group { await MainActor.run { guard self.activeScanID == scanID else { return } self.replaceItem(with: enrichedItem) loadedCount += 1 if loadedCount == discoveredItems.count { self.scanStatus = "Loaded \(loadedCount) items." self.isScanning = false } else { self.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..." } } } } if discoveredItems.isEmpty { isScanning = false } } catch { guard activeScanID == scanID else { return } scanError = "Failed to scan folder: \(error.localizedDescription)" scanStatus = "" isScanning = false } } private func replaceItem(with updatedItem: MinecraftContentItem) { guard let index = items.firstIndex(where: { $0.id == updatedItem.id }) else { return } items[index] = updatedItem items.sort(by: Self.sortItems) } nonisolated private static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] { let fileManager = FileManager.default let resourceKeys: [URLResourceKey] = [.isDirectoryKey] guard let enumerator = fileManager.enumerator( at: searchRootURL, includingPropertiesForKeys: resourceKeys, options: [.skipsHiddenFiles] ) else { return [] } var discoveredItems: [MinecraftContentItem] = [] var seenItemURLs = Set() for case let directoryURL as URL in enumerator { guard (try? directoryURL.resourceValues(forKeys: Set(resourceKeys)).isDirectory) == true else { continue } guard let contentType = contentType(forCollectionFolderName: directoryURL.lastPathComponent) else { continue } let childDirectories = try immediateChildDirectories(of: directoryURL, fileManager: fileManager) for childDirectory in childDirectories { let itemURL = childDirectory.standardizedFileURL guard !seenItemURLs.contains(itemURL) else { continue } if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) { seenItemURLs.insert(itemURL) discoveredItems.append( MinecraftContentItem( folderURL: childDirectory, folderName: childDirectory.lastPathComponent, contentType: contentType, collectionRootURL: directoryURL ) ) } } } discoveredItems.sort(by: sortItems) return discoveredItems } nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { let fileManager = FileManager.default var enrichedItem = item enrichedItem.displayName = displayName(for: item, fileManager: fileManager) enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager) enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager) enrichedItem.metadataLoaded = true return enrichedItem } nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? { let normalizedFolderName = folderName.lowercased() return MinecraftContentType.allCases.first { type in type.collectionFolderName.lowercased() == normalizedFolderName } } nonisolated private static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] { let children = try fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) return children.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } } nonisolated private static func isCandidateItem(at directoryURL: URL, type: MinecraftContentType, fileManager: FileManager) -> Bool { switch type { case .world: return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("levelname.txt").path) case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.png").path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpeg").path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpg").path) } } nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String { switch item.contentType { case .world: let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt") guard let name = try? String(contentsOf: levelNameURL, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return item.folderName } return name case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: if let manifestName = manifestName(in: item.folderURL, fileManager: fileManager) { return manifestName } return item.folderName } } nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? { let manifestURL = directoryURL.appendingPathComponent("manifest.json") guard fileManager.fileExists(atPath: manifestURL.path), let data = try? Data(contentsOf: manifestURL), let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let header = jsonObject["header"] as? [String: Any], let name = (header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil } return name } nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? { let candidateNames: [String] switch item.contentType { case .world: candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"] case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"] } for candidateName in candidateNames { let candidateURL = item.folderURL.appendingPathComponent(candidateName) if fileManager.fileExists(atPath: candidateURL.path) { return candidateURL } } return nil } nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? { try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { guard let enumerator = fileManager.enumerator( at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles] ) else { return nil } var totalSize: Int64 = 0 for case let fileURL as URL in enumerator { guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), values.isRegularFile == true, let fileSize = values.fileSize else { continue } totalSize += Int64(fileSize) } return totalSize } nonisolated private static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool { if lhs.contentType != rhs.contentType { return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending } let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName) if displayNameOrder != .orderedSame { return displayNameOrder == .orderedAscending } return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending } }