256 lines
9.0 KiB
Swift
256 lines
9.0 KiB
Swift
//
|
|
// MinecraftPackageInspector.swift
|
|
// World Manager for Minecraft
|
|
//
|
|
// Created by OpenAI on 2026-05-26.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum MinecraftPackageInspector {
|
|
struct InspectionResult: Sendable {
|
|
let archiveURL: URL
|
|
let extractedRootURL: URL
|
|
let contentRootURL: URL
|
|
let contentType: MinecraftContentType
|
|
let displayName: String
|
|
let iconURL: URL?
|
|
let worldMetadata: WorldMetadata?
|
|
let manifestMetadata: MinecraftManifestMetadata?
|
|
}
|
|
|
|
enum InspectionError: LocalizedError {
|
|
case unsupportedFileType(String)
|
|
case invalidArchiveLayout
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unsupportedFileType(let pathExtension):
|
|
return "Unsupported Minecraft package type: .\(pathExtension)"
|
|
case .invalidArchiveLayout:
|
|
return "The Minecraft package did not contain a valid world or pack layout."
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated static let supportedPathExtensions: Set<String> = [
|
|
"mcaddon",
|
|
"mcpack",
|
|
"mctemplate",
|
|
"mcworld"
|
|
]
|
|
|
|
nonisolated static func inspectArchive(at archiveURL: URL) throws -> InspectionResult {
|
|
let normalizedArchiveURL = archiveURL.standardizedFileURL
|
|
let pathExtension = normalizedArchiveURL.pathExtension.lowercased()
|
|
guard supportedPathExtensions.contains(pathExtension) else {
|
|
throw InspectionError.unsupportedFileType(pathExtension)
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
let extractionDirectoryURL = fileManager.temporaryDirectory
|
|
.appendingPathComponent("MinecraftPackageInspection", isDirectory: true)
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
|
|
try fileManager.createDirectory(at: extractionDirectoryURL, withIntermediateDirectories: true)
|
|
|
|
do {
|
|
let archive = try ZipArchiveReader(url: normalizedArchiveURL)
|
|
let contentRootURL = try materializedContentRootURL(
|
|
for: archive,
|
|
archivePathExtension: pathExtension,
|
|
in: extractionDirectoryURL,
|
|
fileManager: fileManager
|
|
)
|
|
let contentType = try resolvedContentType(
|
|
forArchivePathExtension: pathExtension,
|
|
contentRootURL: contentRootURL,
|
|
fileManager: fileManager
|
|
)
|
|
let displayName = MinecraftContentMetadataReader.displayName(
|
|
for: contentRootURL,
|
|
contentType: contentType,
|
|
fallbackName: normalizedArchiveURL.deletingPathExtension().lastPathComponent,
|
|
fileManager: fileManager
|
|
)
|
|
let worldMetadata: WorldMetadata? =
|
|
contentType == .world ? MinecraftContentMetadataReader.worldMetadata(in: contentRootURL, fileManager: fileManager) : nil
|
|
let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: contentRootURL, fileManager: fileManager)
|
|
let iconURL = MinecraftContentMetadataReader.iconURL(
|
|
for: contentRootURL,
|
|
contentType: contentType,
|
|
fileManager: fileManager
|
|
)
|
|
|
|
return InspectionResult(
|
|
archiveURL: normalizedArchiveURL,
|
|
extractedRootURL: extractionDirectoryURL,
|
|
contentRootURL: contentRootURL,
|
|
contentType: contentType,
|
|
displayName: displayName,
|
|
iconURL: iconURL,
|
|
worldMetadata: worldMetadata,
|
|
manifestMetadata: manifestMetadata
|
|
)
|
|
} catch {
|
|
try? fileManager.removeItem(at: extractionDirectoryURL)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
nonisolated static func cleanup(_ result: InspectionResult) {
|
|
try? FileManager.default.removeItem(at: result.extractedRootURL)
|
|
}
|
|
|
|
nonisolated private static func resolvedContentType(
|
|
forArchivePathExtension pathExtension: String,
|
|
contentRootURL: URL,
|
|
fileManager: FileManager
|
|
) throws -> MinecraftContentType {
|
|
switch pathExtension {
|
|
case "mcworld":
|
|
return .world
|
|
case "mctemplate":
|
|
return .worldTemplate
|
|
case "mcpack", "mcaddon":
|
|
return MinecraftContentMetadataReader.inferredPackContentType(for: contentRootURL, fileManager: fileManager)
|
|
default:
|
|
throw InspectionError.unsupportedFileType(pathExtension)
|
|
}
|
|
}
|
|
|
|
nonisolated private static func materializedContentRootURL(
|
|
for archive: ZipArchiveReader,
|
|
archivePathExtension: String,
|
|
in extractionDirectoryURL: URL,
|
|
fileManager: FileManager
|
|
) throws -> URL {
|
|
let contentRootPath = try resolvedContentRootPath(
|
|
in: archive.entries,
|
|
archivePathExtension: archivePathExtension
|
|
)
|
|
let contentRootURL = extractionDirectoryURL.appendingPathComponent(contentRootPath, isDirectory: true)
|
|
try fileManager.createDirectory(at: contentRootURL, withIntermediateDirectories: true)
|
|
|
|
let requiredRelativePaths = requiredMetadataRelativePaths(
|
|
in: archive.entries,
|
|
contentRootPath: contentRootPath
|
|
)
|
|
|
|
for relativePath in requiredRelativePaths {
|
|
let fullPath = contentRootPath.isEmpty ? relativePath : "\(contentRootPath)/\(relativePath)"
|
|
guard let entry = archive.entry(named: fullPath) else {
|
|
continue
|
|
}
|
|
|
|
let destinationURL = contentRootURL.appendingPathComponent(relativePath)
|
|
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try archive.extract(entry).write(to: destinationURL)
|
|
}
|
|
|
|
return contentRootURL
|
|
}
|
|
|
|
nonisolated private static func resolvedContentRootPath(
|
|
in entries: [ZipArchiveEntry],
|
|
archivePathExtension: String
|
|
) throws -> String {
|
|
let filePaths = entries
|
|
.filter { !$0.isDirectory }
|
|
.map(\.path)
|
|
|
|
if containsContentMarkers(in: filePaths, prefix: "") {
|
|
return ""
|
|
}
|
|
|
|
let rootCandidates = Set(
|
|
filePaths.compactMap { path -> String? in
|
|
guard let firstComponent = path.split(separator: "/").first else {
|
|
return nil
|
|
}
|
|
return String(firstComponent)
|
|
}
|
|
).sorted()
|
|
|
|
let matchingRoots = rootCandidates.filter { containsContentMarkers(in: filePaths, prefix: $0) }
|
|
guard matchingRoots.count == 1 else {
|
|
throw InspectionError.invalidArchiveLayout
|
|
}
|
|
|
|
let root = matchingRoots[0]
|
|
if archivePathExtension == "mcaddon" {
|
|
return root
|
|
}
|
|
|
|
return root
|
|
}
|
|
|
|
nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
|
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
|
|
|
let worldMarkers = ["level.dat", "levelname.txt"]
|
|
let packMarkers = ["manifest.json", "pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
|
|
|
if worldMarkers.contains(where: { filePaths.contains(normalizedPrefix + $0) }) {
|
|
return true
|
|
}
|
|
|
|
if filePaths.contains(normalizedPrefix + "db") || filePaths.contains(where: { $0.hasPrefix(normalizedPrefix + "db/") }) {
|
|
return true
|
|
}
|
|
|
|
return packMarkers.contains(where: { filePaths.contains(normalizedPrefix + $0) })
|
|
}
|
|
|
|
nonisolated private static func requiredMetadataRelativePaths(
|
|
in entries: [ZipArchiveEntry],
|
|
contentRootPath: String
|
|
) -> [String] {
|
|
let fullPrefix = contentRootPath.isEmpty ? "" : contentRootPath + "/"
|
|
let candidateNames = [
|
|
"manifest.json",
|
|
"level.dat",
|
|
"levelname.txt",
|
|
"world_icon.jpeg",
|
|
"world_icon.jpg",
|
|
"world_icon.png",
|
|
"pack_icon.png",
|
|
"pack_icon.jpeg",
|
|
"pack_icon.jpg"
|
|
]
|
|
|
|
let availablePaths = Set(entries.filter { !$0.isDirectory }.map(\.path))
|
|
return candidateNames.filter { availablePaths.contains(fullPrefix + $0) }
|
|
}
|
|
|
|
nonisolated private static func looksLikeMinecraftContentRoot(
|
|
_ directoryURL: URL,
|
|
fileManager: FileManager
|
|
) -> Bool {
|
|
let worldMarkers = [
|
|
"level.dat",
|
|
"levelname.txt"
|
|
]
|
|
let packMarkers = [
|
|
"manifest.json",
|
|
"pack_icon.png",
|
|
"pack_icon.jpeg",
|
|
"pack_icon.jpg"
|
|
]
|
|
|
|
if worldMarkers.contains(where: { fileManager.fileExists(atPath: directoryURL.appendingPathComponent($0).path) }) {
|
|
return true
|
|
}
|
|
|
|
if fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path) {
|
|
return true
|
|
}
|
|
|
|
if packMarkers.contains(where: { fileManager.fileExists(atPath: directoryURL.appendingPathComponent($0).path) }) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|