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

298 lines
11 KiB
Swift

//
// ContentPackageExporter.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import CryptoKit
import Foundation
enum ContentPackageExporter {
enum ExportError: LocalizedError {
case failedToCreateArchive(String)
case failedToPrepareArchiveContents(String)
var errorDescription: String? {
switch self {
case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the archive file." : output
case .failedToPrepareArchiveContents(let message):
return message
}
}
}
nonisolated static func createArchiveFile(
for item: MinecraftContentItem,
source: MinecraftSource? = nil,
destinationURL: URL? = nil
) throws -> URL {
let fileManager = FileManager.default
let archiveURL: URL
if let destinationURL {
archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL)
} else {
archiveURL = try shareArchiveURL(for: item, fileManager: fileManager)
}
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
try createArchive(for: item, source: source, at: archiveURL)
return archiveURL
}
nonisolated static func suggestedBaseFilename(for item: MinecraftContentItem) -> String {
sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName)
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
}
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
normalizedArchiveURL(for: item, destinationURL: destinationURL)
}
nonisolated private static func createArchive(
for item: MinecraftContentItem,
source: MinecraftSource?,
at archiveURL: URL
) throws {
let fileManager = FileManager.default
let stagingDirectoryURL = try stagedArchiveContents(for: item, source: source, fileManager: fileManager)
defer {
try? fileManager.removeItem(at: stagingDirectoryURL)
}
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = stagingDirectoryURL
process.arguments = [
"-c",
"-k",
"--norsrc",
".",
archiveURL.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
throw ExportError.failedToCreateArchive(output)
}
}
nonisolated private static func stagedArchiveContents(
for item: MinecraftContentItem,
source: MinecraftSource?,
fileManager: FileManager
) throws -> URL {
let stagingDirectoryURL = fileManager.temporaryDirectory
.appendingPathComponent("MinecraftArchiveStaging", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: stagingDirectoryURL, withIntermediateDirectories: true)
let accessURL = try archiveAccessURL(for: item, source: source)
let accessedSecurityScope = accessURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
accessURL.stopAccessingSecurityScopedResource()
}
}
do {
let contents = try fileManager.contentsOfDirectory(
at: item.folderURL,
includingPropertiesForKeys: nil,
options: [.skipsPackageDescendants]
)
for entryURL in contents {
let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent)
try fileManager.copyItem(at: entryURL, to: destinationURL)
}
} catch {
throw ExportError.failedToPrepareArchiveContents(
"Could not prepare the item for archiving: \(error.localizedDescription)"
)
}
return stagingDirectoryURL
}
nonisolated private static func shareArchiveDirectory(fileManager: FileManager) throws -> URL {
let baseDirectoryURL = try fileManager.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let shareDirectoryURL = baseDirectoryURL
.appendingPathComponent("MinecraftContentShares", isDirectory: true)
try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true)
return shareDirectoryURL
}
nonisolated private static func shareArchiveURL(
for item: MinecraftContentItem,
fileManager: FileManager
) throws -> URL {
let shareDirectoryURL = try shareArchiveDirectory(fileManager: fileManager)
let itemDirectoryURL = shareDirectoryURL
.appendingPathComponent(shareCacheKey(for: item), isDirectory: true)
let requestDirectoryURL = itemDirectoryURL
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: requestDirectoryURL, withIntermediateDirectories: true)
cleanupShareArchives(in: itemDirectoryURL, keeping: requestDirectoryURL, fileManager: fileManager)
return requestDirectoryURL
.appendingPathComponent(suggestedBaseFilename(for: item))
.appendingPathExtension(item.contentType.archiveExtension)
}
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String {
let digest = SHA256.hash(data: Data(item.id.path.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
nonisolated private static func cleanupShareArchives(
in itemDirectoryURL: URL,
keeping currentDirectoryURL: URL,
fileManager: FileManager
) {
guard let childDirectoryURLs = try? fileManager.contentsOfDirectory(
at: itemDirectoryURL,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let staleDirectories = childDirectoryURLs
.filter { $0 != currentDirectoryURL }
.sorted { lhs, rhs in
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
return lhsDate > rhsDate
}
.dropFirst(2)
for directoryURL in staleDirectories {
try? fileManager.removeItem(at: directoryURL)
}
}
nonisolated private static func archiveAccessURL(
for item: MinecraftContentItem,
source: MinecraftSource?
) throws -> URL {
guard let source else {
return item.folderURL
}
guard case .localFolder(let bookmarkData) = source.origin else {
return source.folderURL
}
guard let bookmarkData else {
return source.folderURL
}
var isStale = false
return try URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
).standardizedFileURL
}
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL
}
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
}
nonisolated private static func uniqueArchiveURL(
in directoryURL: URL,
baseName: String,
pathExtension: String,
fileManager: FileManager
) -> URL {
var candidateURL = directoryURL
.appendingPathComponent(baseName)
.appendingPathExtension(pathExtension)
var suffix = 2
while fileManager.fileExists(atPath: candidateURL.path) {
candidateURL = directoryURL
.appendingPathComponent("\(baseName) \(suffix)")
.appendingPathExtension(pathExtension)
suffix += 1
}
return candidateURL
}
nonisolated private static func sanitizedFilename(_ value: String) -> String {
let transliterated = portableASCIIString(from: value)
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = transliterated.components(separatedBy: invalidCharacters)
let collapsed = components.joined(separator: " ")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_().,'&+"))
let filteredScalars = collapsed.unicodeScalars.map { scalar in
allowedCharacters.contains(scalar) ? Character(scalar) : " "
}
let filtered = String(filteredScalars)
let normalizedWhitespace = filtered.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_"))
return trimmedPunctuation.isEmpty ? "Minecraft Item" : trimmedPunctuation
}
nonisolated private static func portableASCIIString(from value: String) -> String {
let mutable = NSMutableString(string: value) as CFMutableString
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
return mutable as String
}
}