world-manager/World Manager for Minecraft/Services/WorldScanner.swift

491 lines
19 KiB
Swift

//
// WorldScanner.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum WorldScanner {
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
let fileManager = FileManager.default
var sizedItem = item
sizedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager)
sizedItem.sizeLoaded = true
return sizedItem
}
nonisolated static func beginScanSession(for sourceRootURL: URL) async {
await packReferenceIndexStore.reset(for: sourceRootURL)
}
nonisolated static func endScanSession(for sourceRootURL: URL) async {
await packReferenceIndexStore.reset(for: sourceRootURL)
}
nonisolated static func discoverItems(
in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
) 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<URL>()
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) {
let item = MinecraftContentItem(
folderURL: childDirectory,
folderName: childDirectory.lastPathComponent,
contentType: contentType,
collectionRootURL: directoryURL
)
seenItemURLs.insert(itemURL)
discoveredItems.append(item)
onDiscovered(item)
if contentType == .world {
let embeddedPackItems = discoverEmbeddedPackItems(
in: childDirectory,
fileManager: fileManager,
seenItemURLs: &seenItemURLs
)
discoveredItems.append(contentsOf: embeddedPackItems)
embeddedPackItems.forEach(onDiscovered)
}
}
}
}
discoveredItems.sort(by: sortItems)
return discoveredItems
}
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
let fileManager = FileManager.default
var enrichedItem = item
enrichedItem.displayName = MinecraftContentMetadataReader.displayName(
for: item.folderURL,
contentType: item.contentType,
fallbackName: item.folderName,
fileManager: fileManager
)
let sourceIconURL = MinecraftContentMetadataReader.iconURL(
for: item.folderURL,
contentType: item.contentType,
fileManager: fileManager
)
enrichedItem.iconURL = await ImageCacheStore.shared.cachedImageURL(for: sourceIconURL)
enrichedItem.worldMetadata = item.contentType == .world
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
: nil
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
enrichedItem.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version
enrichedItem.packMetadataDetails = PackMetadataDetails(
minimumEngineVersion: manifestMetadata.minimumEngineVersion
)
if !manifestMetadata.name.isEmpty {
enrichedItem.displayName = manifestMetadata.name
}
}
enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager)
enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
enrichedItem.sizeLoaded = false
return enrichedItem
}
nonisolated 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
}
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {
let normalizedFolderName = folderName.lowercased()
return MinecraftContentType.allCases.first { type in
type.collectionFolderName.lowercased() == normalizedFolderName
}
}
nonisolated fileprivate 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 discoverEmbeddedPackItems(
in worldDirectoryURL: URL,
fileManager: FileManager,
seenItemURLs: inout Set<URL>
) -> [MinecraftContentItem] {
let embeddedCollections: [(MinecraftContentType, URL)] = [
(.behaviorPack, worldDirectoryURL.appendingPathComponent("behavior_packs", isDirectory: true)),
(.resourcePack, worldDirectoryURL.appendingPathComponent("resource_packs", isDirectory: true))
]
var embeddedItems: [MinecraftContentItem] = []
for (contentType, collectionURL) in embeddedCollections {
guard
fileManager.fileExists(atPath: collectionURL.path),
let childDirectories = try? immediateChildDirectories(of: collectionURL, fileManager: fileManager)
else {
continue
}
for childDirectory in childDirectories {
let itemURL = childDirectory.standardizedFileURL
guard !seenItemURLs.contains(itemURL) else {
continue
}
guard isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) else {
continue
}
let item = MinecraftContentItem(
folderURL: childDirectory,
folderName: childDirectory.lastPathComponent,
contentType: contentType,
collectionRootURL: collectionURL
)
seenItemURLs.insert(itemURL)
embeddedItems.append(item)
}
}
return embeddedItems
}
nonisolated private static func lastPlayedDate(
for item: MinecraftContentItem,
fileManager: FileManager,
worldMetadata: WorldMetadata?
) -> Date? {
guard item.contentType == .world else {
return nil
}
_ = fileManager
return worldMetadata?.lastPlayedDate
}
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 packReferences(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] {
switch item.contentType {
case .world:
var references = await referencedWorldPacks(for: item, fileManager: fileManager)
references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager))
return uniquePackReferences(references)
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return []
}
}
nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) async -> [ContentPackReference] {
let behaviorReferences = await packReferences(
fromWorldReferenceFileNamed: "world_behavior_packs.json",
type: .behaviorPack,
worldFolderURL: item.folderURL,
fileManager: fileManager
)
let resourceReferences = await packReferences(
fromWorldReferenceFileNamed: "world_resource_packs.json",
type: .resourcePack,
worldFolderURL: item.folderURL,
fileManager: fileManager
)
return behaviorReferences + resourceReferences
}
nonisolated private static func embeddedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] {
var references: [ContentPackReference] = []
references.append(
contentsOf: embeddedPackReferences(
in: item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true),
type: .behaviorPack,
fileManager: fileManager
)
)
references.append(
contentsOf: embeddedPackReferences(
in: item.folderURL.appendingPathComponent("resource_packs", isDirectory: true),
type: .resourcePack,
fileManager: fileManager
)
)
return references
}
nonisolated private static func packReferences(
fromWorldReferenceFileNamed filename: String,
type: MinecraftContentType,
worldFolderURL: URL,
fileManager: FileManager
) async -> [ContentPackReference] {
let fileURL = worldFolderURL.appendingPathComponent(filename)
guard
fileManager.fileExists(atPath: fileURL.path),
let data = try? Data(contentsOf: fileURL),
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
return []
}
var references: [ContentPackReference] = []
for entry in jsonObject {
let uuid = (entry["pack_id"] as? String)?.lowercased()
let version = MinecraftContentMetadataReader.versionString(from: entry["version"])
let resolvedPack: ContentPackReference?
if let uuid {
resolvedPack = await resolvedPackReference(
uuid: uuid,
type: type,
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent()
)
} else {
resolvedPack = nil
}
let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack"
references.append(
ContentPackReference(
name: fallbackName,
type: type,
iconURL: resolvedPack?.iconURL,
uuid: uuid,
version: resolvedPack?.version ?? version,
source: .referencedByWorld
)
)
}
return references
}
nonisolated private static func embeddedPackReferences(
in directoryURL: URL,
type: MinecraftContentType,
fileManager: FileManager
) -> [ContentPackReference] {
guard
fileManager.fileExists(atPath: directoryURL.path),
let childDirectories = try? immediateChildDirectories(of: directoryURL, fileManager: fileManager)
else {
return []
}
return childDirectories.compactMap { childDirectory in
packReference(
fromPackFolder: childDirectory,
type: type,
source: .embeddedInWorld,
fileManager: fileManager
)
}
}
nonisolated fileprivate static func packReference(
fromPackFolder directoryURL: URL,
type: MinecraftContentType,
source: PackSource,
fileManager: FileManager
) -> ContentPackReference? {
guard let metadata = MinecraftContentMetadataReader.manifestMetadata(in: directoryURL, fileManager: fileManager) else {
return nil
}
return ContentPackReference(
name: metadata.name,
type: type,
iconURL: MinecraftContentMetadataReader.packIconURL(in: directoryURL, fileManager: fileManager),
uuid: metadata.uuid,
version: metadata.version,
source: source
)
}
nonisolated private static func resolvedPackReference(
uuid: String,
type: MinecraftContentType,
worldCollectionRootURL: URL
) async -> ContentPackReference? {
let siblingCollectionURL = worldCollectionRootURL
.deletingLastPathComponent()
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
return await packReferenceIndexStore.reference(
forUUID: uuid,
type: type,
in: siblingCollectionURL
)
}
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
var seen = Set<String>()
var uniqueReferences: [ContentPackReference] = []
for reference in references {
let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""]
.joined(separator: "::")
guard seen.insert(dedupeKey).inserted else {
continue
}
uniqueReferences.append(reference)
}
return uniqueReferences.sorted { lhs, rhs in
if lhs.type != rhs.type {
return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
}
private actor PackReferenceIndexStore {
private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:]
func reset(for sourceRootURL: URL) {
let sourceRootPath = sourceRootURL.standardizedFileURL.path
referencesByCollectionURL = referencesByCollectionURL.filter { collectionURL, _ in
!collectionURL.standardizedFileURL.path.hasPrefix(sourceRootPath + "/")
}
}
func reference(forUUID uuid: String, type: MinecraftContentType, in collectionURL: URL) -> ContentPackReference? {
let normalizedCollectionURL = collectionURL.standardizedFileURL
if let cachedReferences = referencesByCollectionURL[normalizedCollectionURL] {
return cachedReferences[uuid]
}
let fileManager = FileManager.default
guard
fileManager.fileExists(atPath: normalizedCollectionURL.path),
let childDirectories = try? WorldScanner.immediateChildDirectories(
of: normalizedCollectionURL,
fileManager: fileManager
)
else {
referencesByCollectionURL[normalizedCollectionURL] = [:]
return nil
}
var referencesByUUID: [String: ContentPackReference] = [:]
for childDirectory in childDirectories {
guard
let reference = WorldScanner.packReference(
fromPackFolder: childDirectory,
type: type,
source: .foundInCollection,
fileManager: fileManager
),
let referenceUUID = reference.uuid
else {
continue
}
referencesByUUID[referenceUUID] = reference
}
referencesByCollectionURL[normalizedCollectionURL] = referencesByUUID
return referencesByUUID[uuid]
}
}
private let packReferenceIndexStore = PackReferenceIndexStore()