Compare commits

..

2 Commits

Author SHA1 Message Date
John Burwell
e5345b91d8 Update for async export and refresh 2026-08-02 19:57:14 -05:00
John Burwell
e0886ee50f Allow device exports during size scans 2026-07-25 11:40:20 -03:00
24 changed files with 1517 additions and 80 deletions

View File

@ -17,6 +17,8 @@ This thing is called "World Manager," and it happens to pertain to someone else'
- Detect worlds, resource packs, behavior packs, and world templates.
- Inspect package metadata, pack relationships, icons, versions, UUIDs, and basic world facts.
- Export worlds and packs as portable `.mcworld`, `.mcpack`, `.mctemplate`, and `.mcaddon` packages, or share them via Messages, AirDrop, etc. via the "Share" sheet.
- Import `.mcworld`, `.mcpack`, `.mctemplate`, and nested-package `.mcaddon` files into writable folder and connected-device sources.
- Copy an item between sources by dragging it onto the destination source in the sidebar.
- Preview and thumbnail supported Minecraft package files with Quick Look extensions.
## Usage
@ -43,6 +45,14 @@ From an item's detail view, you can export it to a Minecraft package file on you
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
### Importing and Copying
Select a source and use `Import...` on its overview, or drop a supported Minecraft package onto the source row in the sidebar. World Manager inspects the package and places each item in the collection appropriate to its content type.
You can also drag an existing world or pack from the item list onto another source. The app exports a temporary portable representation, validates it through the same import path, and installs it in the destination.
Imports are additive. Existing worlds are never overwritten. A second world import receives a unique directory name. Packs with a UUID already present in the destination are rejected instead of being silently replaced.
## Requirements
- macOS 26.2 or newer, based on the current Xcode project deployment target. Could work with older macOS. I don't have any so I don't know.
@ -97,7 +107,7 @@ See [docs/ios-device-access.md](docs/ios-device-access.md) for the current devic
## Project Status
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. The project is currently focused on read-only library access and export, and connected-device discovery, so it shouldn't be doing anything that can break your existing libraries.
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. Connected-device installation writes through private Apple MobileDevice interfaces and should be treated as best-effort. The initial implementation installs only into new directories and does not replace or remove existing content.
## Trademarks

View File

@ -74,6 +74,7 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -73,6 +73,7 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -7,16 +7,19 @@ nonisolated struct SourceCapabilities: Hashable, Sendable, Codable {
var canScan: Bool = true
var canMaterializeItems: Bool = true
var canExportPortablePackages: Bool = true
var canInstallItems: Bool = false
static let localFolder = SourceCapabilities(
canScan: true,
canMaterializeItems: true,
canExportPortablePackages: true
canExportPortablePackages: true,
canInstallItems: true
)
static let connectedDevice = SourceCapabilities(
canScan: true,
canMaterializeItems: true,
canExportPortablePackages: true
canExportPortablePackages: true,
canInstallItems: true
)
}

View File

@ -0,0 +1,361 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
nonisolated struct InstallationPayload: Sendable {
let contentType: MinecraftContentType
let preparedDirectoryURL: URL
let displayName: String
let packUUID: String?
let packVersion: String?
let suggestedFolderName: String
}
nonisolated struct InstallationPlan: Sendable {
let payloads: [InstallationPayload]
let stagingRootURL: URL
func cleanup(fileManager: FileManager = .default) {
try? fileManager.removeItem(at: stagingRootURL)
}
}
nonisolated struct InstalledContentItem: Sendable {
let contentType: MinecraftContentType
let displayName: String
let destinationName: String
}
nonisolated struct SourceInstallationState: Hashable, Sendable {
let completedCount: Int
let totalCount: Int
let status: String
}
enum MinecraftPackageInstaller {
enum InstallError: LocalizedError {
case emptyPackage
case unsupportedAddonLayout
case archiveTooLarge
case tooManyEntries
case symbolicLinksNotSupported
case invalidPreparedContent
case duplicatePack(name: String, version: String?)
case duplicateArchivePath(String)
case partialInstallation(completed: Int, total: Int, reason: String)
var errorDescription: String? {
switch self {
case .emptyPackage:
return "The Minecraft package does not contain any files."
case .unsupportedAddonLayout:
return "The .mcaddon package does not contain any supported Minecraft package files."
case .archiveTooLarge:
return "The expanded Minecraft package is too large to import safely."
case .tooManyEntries:
return "The Minecraft package contains too many files to import safely."
case .symbolicLinksNotSupported:
return "Minecraft packages containing symbolic links are not supported."
case .invalidPreparedContent:
return "The prepared content does not contain a valid Minecraft world, pack, or template."
case .duplicatePack(let name, let version):
if let version, !version.isEmpty {
return "\(name) version \(version) is already installed in this source."
}
return "\(name) is already installed in this source."
case .duplicateArchivePath(let path):
return "The Minecraft package contains more than one entry for \(path)."
case .partialInstallation(let completed, let total, let reason):
return "Installed \(completed) of \(total) items before the import stopped: \(reason)"
}
}
}
private static let maximumEntryCount = 100_000
private static let maximumExpandedSize: UInt64 = 16 * 1_024 * 1_024 * 1_024
nonisolated static func preparePackage(at packageURL: URL) throws -> InstallationPlan {
let fileManager = FileManager.default
let stagingRootURL = fileManager.temporaryDirectory
.appendingPathComponent("MinecraftPackageInstallation", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
do {
let payloads = try preparePackage(
at: packageURL.standardizedFileURL,
inside: stagingRootURL,
fileManager: fileManager
)
guard !payloads.isEmpty else {
throw InstallError.emptyPackage
}
return InstallationPlan(payloads: payloads, stagingRootURL: stagingRootURL)
} catch {
try? fileManager.removeItem(at: stagingRootURL)
throw error
}
}
nonisolated static func prepareDirectory(
at directoryURL: URL,
contentType: MinecraftContentType
) throws -> InstallationPlan {
let fileManager = FileManager.default
let stagingRootURL = fileManager.temporaryDirectory
.appendingPathComponent("MinecraftDirectoryInstallation", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let payloadDirectoryURL = stagingRootURL.appendingPathComponent("payload", isDirectory: true)
do {
try validateDirectoryTree(at: directoryURL, fileManager: fileManager)
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
try fileManager.copyItem(at: directoryURL, to: payloadDirectoryURL)
let payload = try makePayload(
contentRootURL: payloadDirectoryURL,
contentType: contentType,
fallbackName: directoryURL.lastPathComponent,
fileManager: fileManager
)
return InstallationPlan(payloads: [payload], stagingRootURL: stagingRootURL)
} catch {
try? fileManager.removeItem(at: stagingRootURL)
throw error
}
}
private nonisolated static func preparePackage(
at packageURL: URL,
inside stagingRootURL: URL,
fileManager: FileManager
) throws -> [InstallationPayload] {
let pathExtension = packageURL.pathExtension.lowercased()
guard MinecraftPackageInspector.supportedPathExtensions.contains(pathExtension) else {
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
}
let archive = try ZipArchiveReader(url: packageURL)
try validate(entries: archive.entries)
if pathExtension == "mcaddon" {
return try prepareAddon(
archive: archive,
packageURL: packageURL,
stagingRootURL: stagingRootURL,
fileManager: fileManager
)
}
let contentRootPath = try MinecraftPackageInspector.resolvedContentRootPath(
in: archive.entries,
archivePathExtension: pathExtension
)
let payloadDirectoryURL = stagingRootURL
.appendingPathComponent("payload-\(UUID().uuidString)", isDirectory: true)
try extract(
archive: archive,
contentRootPath: contentRootPath,
to: payloadDirectoryURL,
fileManager: fileManager
)
let contentType: MinecraftContentType
switch pathExtension {
case "mcworld":
contentType = .world
case "mctemplate":
contentType = .worldTemplate
case "mcpack":
contentType = MinecraftContentMetadataReader.inferredPackContentType(
for: payloadDirectoryURL,
fileManager: fileManager
)
default:
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
}
return [
try makePayload(
contentRootURL: payloadDirectoryURL,
contentType: contentType,
fallbackName: packageURL.deletingPathExtension().lastPathComponent,
fileManager: fileManager
)
]
}
private nonisolated static func prepareAddon(
archive: ZipArchiveReader,
packageURL: URL,
stagingRootURL: URL,
fileManager: FileManager
) throws -> [InstallationPayload] {
let nestedEntries = archive.entries.filter { entry in
guard !entry.isDirectory else {
return false
}
return ["mcpack", "mcworld", "mctemplate"].contains(
URL(fileURLWithPath: entry.path).pathExtension.lowercased()
)
}
guard !nestedEntries.isEmpty else {
throw InstallError.unsupportedAddonLayout
}
var payloads: [InstallationPayload] = []
for nestedEntry in nestedEntries {
let nestedPackageURL = stagingRootURL
.appendingPathComponent("nested-\(UUID().uuidString)")
.appendingPathExtension(URL(fileURLWithPath: nestedEntry.path).pathExtension)
try archive.extract(nestedEntry).write(to: nestedPackageURL, options: .atomic)
payloads.append(
contentsOf: try preparePackage(
at: nestedPackageURL,
inside: stagingRootURL,
fileManager: fileManager
)
)
try? fileManager.removeItem(at: nestedPackageURL)
}
_ = packageURL
return payloads
}
private nonisolated static func validate(entries: [ZipArchiveEntry]) throws {
guard !entries.isEmpty else {
throw InstallError.emptyPackage
}
guard entries.count <= maximumEntryCount else {
throw InstallError.tooManyEntries
}
guard !entries.contains(where: \.isSymbolicLink) else {
throw InstallError.symbolicLinksNotSupported
}
var paths = Set<String>()
for entry in entries where !entry.isDirectory {
guard paths.insert(entry.path).inserted else {
throw InstallError.duplicateArchivePath(entry.path)
}
}
let expandedSize = entries.reduce(UInt64(0)) { partial, entry in
partial + UInt64(entry.uncompressedSize)
}
guard expandedSize <= maximumExpandedSize else {
throw InstallError.archiveTooLarge
}
}
private nonisolated static func extract(
archive: ZipArchiveReader,
contentRootPath: String,
to destinationRootURL: URL,
fileManager: FileManager
) throws {
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
let prefix = contentRootPath.isEmpty ? "" : contentRootPath + "/"
for entry in archive.entries {
guard entry.path.hasPrefix(prefix) else {
continue
}
let relativePath = String(entry.path.dropFirst(prefix.count))
guard !relativePath.isEmpty else {
continue
}
let destinationURL = destinationRootURL.appendingPathComponent(relativePath)
if entry.isDirectory {
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
continue
}
try fileManager.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try archive.extract(entry).write(to: destinationURL, options: .atomic)
}
}
private nonisolated static func makePayload(
contentRootURL: URL,
contentType: MinecraftContentType,
fallbackName: String,
fileManager: FileManager
) throws -> InstallationPayload {
guard isValidContent(at: contentRootURL, type: contentType, fileManager: fileManager) else {
throw InstallError.invalidPreparedContent
}
let manifest = MinecraftContentMetadataReader.manifestMetadata(
in: contentRootURL,
fileManager: fileManager
)
let displayName = MinecraftContentMetadataReader.displayName(
for: contentRootURL,
contentType: contentType,
fallbackName: fallbackName,
fileManager: fileManager
)
let preferredFolderName = manifest?.uuid ?? fallbackName
return InstallationPayload(
contentType: contentType,
preparedDirectoryURL: contentRootURL,
displayName: displayName,
packUUID: manifest?.uuid,
packVersion: manifest?.version,
suggestedFolderName: sanitizedFolderName(preferredFolderName)
)
}
private nonisolated static func isValidContent(
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)
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path)
}
}
private nonisolated static func sanitizedFolderName(_ value: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = value.components(separatedBy: invalidCharacters)
let sanitized = components.joined(separator: "-")
.trimmingCharacters(in: .whitespacesAndNewlines)
return sanitized.isEmpty ? UUID().uuidString : String(sanitized.prefix(80))
}
private nonisolated static func validateDirectoryTree(
at directoryURL: URL,
fileManager: FileManager
) throws {
guard let enumerator = fileManager.enumerator(
at: directoryURL,
includingPropertiesForKeys: [.isSymbolicLinkKey],
options: []
) else {
throw InstallError.invalidPreparedContent
}
var entryCount = 0
for case let entryURL as URL in enumerator {
entryCount += 1
guard entryCount <= maximumEntryCount else {
throw InstallError.tooManyEntries
}
if (try entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true {
throw InstallError.symbolicLinksNotSupported
}
}
}
}

View File

@ -147,7 +147,7 @@ enum MinecraftPackageInspector {
return contentRootURL
}
nonisolated private static func resolvedContentRootPath(
nonisolated static func resolvedContentRootPath(
in entries: [ZipArchiveEntry],
archivePathExtension: String
) throws -> String {
@ -181,7 +181,7 @@ enum MinecraftPackageInspector {
return root
}
nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
nonisolated static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
let worldMarkers = ["level.dat", "levelname.txt"]

View File

@ -11,12 +11,14 @@ nonisolated struct ZipArchiveEntry: Sendable, Hashable {
let uncompressedSize: UInt32
let localHeaderOffset: UInt32
let isDirectory: Bool
let isSymbolicLink: Bool
}
nonisolated enum ZipArchiveReaderError: LocalizedError {
case invalidArchive
case unsupportedCompressionMethod(UInt16)
case unsupportedFeatures(String)
case unsafeEntryPath(String)
case entryNotFound(String)
case decompressionFailed(Int32)
@ -28,6 +30,8 @@ nonisolated enum ZipArchiveReaderError: LocalizedError {
return "Unsupported ZIP compression method: \(method)."
case .unsupportedFeatures(let message):
return message
case .unsafeEntryPath(let path):
return "The ZIP archive contains an unsafe entry path: \(path)"
case .entryNotFound(let path):
return "ZIP entry not found: \(path)"
case .decompressionFailed(let code):
@ -41,7 +45,7 @@ nonisolated struct ZipArchiveReader {
let entries: [ZipArchiveEntry]
init(url: URL) throws {
self.data = try Data(contentsOf: url)
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
self.entries = try ZipArchiveReader.parseEntries(in: data)
}
@ -78,6 +82,9 @@ nonisolated struct ZipArchiveReader {
switch entry.compressionMethod {
case 0:
guard compressedData.count == Int(entry.uncompressedSize) else {
throw ZipArchiveReaderError.invalidArchive
}
return compressedData
case 8:
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
@ -115,6 +122,7 @@ nonisolated struct ZipArchiveReader {
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
let externalAttributes = data.readUInt32LE(at: offset + 38)
let filenameStart = offset + 46
let filenameEnd = filenameStart + filenameLength
@ -127,7 +135,9 @@ nonisolated struct ZipArchiveReader {
throw ZipArchiveReaderError.invalidArchive
}
try validateEntryPath(filename)
let normalizedPath = Self.normalizedPath(filename)
let unixMode = UInt16((externalAttributes >> 16) & 0xffff)
entries.append(
ZipArchiveEntry(
path: normalizedPath,
@ -135,7 +145,8 @@ nonisolated struct ZipArchiveReader {
compressedSize: compressedSize,
uncompressedSize: uncompressedSize,
localHeaderOffset: localHeaderOffset,
isDirectory: normalizedPath.hasSuffix("/")
isDirectory: normalizedPath.hasSuffix("/"),
isSymbolicLink: unixMode & 0o170000 == 0o120000
)
)
@ -178,6 +189,18 @@ nonisolated struct ZipArchiveReader {
return joined
}
private static func validateEntryPath(_ path: String) throws {
let replaced = path.replacingOccurrences(of: "\\", with: "/")
let components = replaced.split(separator: "/", omittingEmptySubsequences: false)
guard
!replaced.hasPrefix("/"),
!replaced.contains("\0"),
!components.contains(where: { $0 == ".." })
else {
throw ZipArchiveReaderError.unsafeEntryPath(path)
}
}
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
if data.isEmpty {
return Data()
@ -228,11 +251,17 @@ nonisolated struct ZipArchiveReader {
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
if producedByteCount > 0 {
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
guard output.count <= expectedSize else {
throw ZipArchiveReaderError.invalidArchive
}
}
}
} while status != Z_STREAM_END
}
guard output.count == expectedSize else {
throw ZipArchiveReaderError.invalidArchive
}
return output
}
}

View File

@ -29,6 +29,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var isRestoringPersistedSources = true
@Published var installationStateBySourceID: [URL: SourceInstallationState] = [:]
private var scanTasks: [URL: Task<Void, Never>] = [:]
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
@ -250,6 +251,143 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
}
func installPackages(at packageURLs: [URL], into sourceID: URL) async throws -> [InstalledContentItem] {
guard
let destinationSource = source(withID: sourceID),
destinationSource.availability == .available,
destinationSource.capabilities.canInstallItems
else {
throw SourceAccessError.accessFailed(reason: "This source is not currently available for installation.")
}
guard !packageURLs.isEmpty else {
return []
}
scanTasks[sourceID]?.cancel()
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: 0,
totalCount: packageURLs.count,
status: "Preparing import..."
)
var plans: [InstallationPlan] = []
var installedItems: [InstalledContentItem] = []
var totalPayloadCount = 0
do {
for packageURL in packageURLs {
let plan = try await Task.detached(priority: .userInitiated) {
let accessedSecurityScope = packageURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
packageURL.stopAccessingSecurityScopedResource()
}
}
return try MinecraftPackageInstaller.preparePackage(at: packageURL)
}.value
plans.append(plan)
}
let payloads = plans.flatMap(\.payloads)
totalPayloadCount = payloads.count
try validatePackConflicts(payloads, destinationSource: destinationSource)
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: 0,
totalCount: payloads.count,
status: payloads.count == 1 ? "Installing item..." : "Installing \(payloads.count) items..."
)
for payload in payloads {
guard let currentDestination = source(withID: sourceID) else {
throw SourceAccessError.accessFailed(reason: "The destination source was removed during installation.")
}
let installedItem = try await sourceAccessMethod.install(payload, in: currentDestination)
installedItems.append(installedItem)
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: installedItems.count,
totalCount: payloads.count,
status: "Installed \(installedItems.count) of \(payloads.count)..."
)
}
plans.forEach { $0.cleanup() }
installationStateBySourceID[sourceID] = nil
startScan(for: sourceID, mode: .fullScan)
return installedItems
} catch {
plans.forEach { $0.cleanup() }
installationStateBySourceID[sourceID] = nil
if !installedItems.isEmpty {
startScan(for: sourceID, mode: .fullScan)
throw MinecraftPackageInstaller.InstallError.partialInstallation(
completed: installedItems.count,
total: totalPayloadCount,
reason: error.localizedDescription
)
}
throw error
}
}
func copyItem(
_ item: MinecraftContentItem,
from sourceID: URL,
into destinationSourceID: URL
) async throws -> [InstalledContentItem] {
guard let source = source(withID: sourceID) else {
throw SourceAccessError.accessFailed(reason: "The source item is no longer available.")
}
let representation = try await externalRepresentation(
for: item,
in: source,
preferredKind: .portablePackage
)
defer {
if representation.isTemporary {
try? FileManager.default.removeItem(at: representation.url)
}
}
return try await installPackages(at: [representation.url], into: destinationSourceID)
}
private func validatePackConflicts(
_ payloads: [InstallationPayload],
destinationSource: MinecraftSource
) throws {
var existingIdentities = Set(
destinationSource.rawItems.compactMap { item -> String? in
guard
item.contentType == .behaviorPack ||
item.contentType == .resourcePack ||
item.contentType == .skinPack,
let uuid = item.packUUID?.lowercased()
else {
return nil
}
return "\(item.contentType.rawValue)::\(uuid)"
}
)
for payload in payloads {
guard
payload.contentType == .behaviorPack ||
payload.contentType == .resourcePack ||
payload.contentType == .skinPack,
let uuid = payload.packUUID?.lowercased()
else {
continue
}
let identity = "\(payload.contentType.rawValue)::\(uuid)"
guard !existingIdentities.contains(identity) else {
throw MinecraftPackageInstaller.InstallError.duplicatePack(
name: payload.displayName,
version: payload.packVersion
)
}
existingIdentities.insert(identity)
}
}
func removeSource(withID sourceID: URL) {
let removedSource = source(withID: sourceID)
scanTasks[sourceID]?.cancel()

View File

@ -120,7 +120,8 @@ enum SourcePresentation {
case .discovering, .metadata, .previews:
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
case .sizing:
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
let remainingCount = max(source.indexedItemCount - source.sizeLoadedCount, 0)
return "Calculating sizes for \(remainingCount) of \(source.indexedItemCount) items..."
case .completed:
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
case .idle:

View File

@ -227,23 +227,6 @@ enum SourceScanExecutor {
}
host.persistSourceIfAvailable(withID: sourceID)
if source.origin.kind == .connectedDevice {
try await finishConnectedDeviceScan(
sourceID: sourceID,
source: source,
host: host,
sourceAccessMethod: sourceAccessMethod,
notificationService: notificationService,
index: index,
discoveredCount: discoveredCount,
scanStartTime: scanStartTime,
scanContextURL: scanContextURL,
performanceContext: performanceContext,
minimumVisibleScanDuration: minimumVisibleScanDuration
)
return
}
let sizeQueue = EnrichmentWorkQueue()
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
Task.detached(priority: .utility) {
@ -322,52 +305,6 @@ enum SourceScanExecutor {
}
}
private static func finishConnectedDeviceScan(
sourceID: URL,
source: MinecraftSource,
host: SourceScanSessionHosting,
sourceAccessMethod: SourceAccessMethod,
notificationService: ScanNotificationServicing,
index: SourceIndexActor,
discoveredCount: Int,
scanStartTime: Date,
scanContextURL: URL,
performanceContext: String,
minimumVisibleScanDuration: TimeInterval
) async throws {
let sizeStageStartTime = Date()
let sizeSeedItems = await index.currentItems()
let sizedItems = await sourceAccessMethod.loadSizeAssets(
for: sizeSeedItems.filter { !$0.sizeLoaded },
in: source
)
for sizedItem in sizedItems {
if let snapshot = await index.applySizedItem(sizedItem) {
host.applySnapshot(snapshot, to: sourceID)
}
}
host.logScanStage(
"Size",
elapsed: Date().timeIntervalSince(sizeStageStartTime),
context: performanceContext,
itemCount: discoveredCount
)
try await finishScan(
sourceID: sourceID,
source: source,
host: host,
notificationService: notificationService,
index: index,
discoveredCount: discoveredCount,
scanStartTime: scanStartTime,
scanContextURL: scanContextURL,
performanceContext: performanceContext,
minimumVisibleScanDuration: minimumVisibleScanDuration
)
}
private static func finishScan(
sourceID: URL,
source: MinecraftSource,
@ -813,7 +750,8 @@ private actor SourceIndexActor {
} else if sizeLoadedCount == 0 {
scanStatus = "Preparing size calculations..."
} else {
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
}
} else {
scanStatus = indexedItemCount == 0

View File

@ -177,6 +177,37 @@ enum AppleMobileDeviceAccess {
}
}
static func installDirectory(
deviceIdentifier: String,
bundleIdentifier: String,
minecraftRootRelativePath: String,
collectionFolderName: String,
preferredDestinationName: String,
sourceDirectoryURL: URL
) async throws -> String {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) {
var error: NSError?
guard let installedName = WMMInstallLocalDirectoryInConnectedDeviceApp(
deviceIdentifier,
bundleIdentifier,
minecraftRootRelativePath,
collectionFolderName,
preferredDestinationName,
sourceDirectoryURL,
&error
) else {
throw error ?? NSError(
domain: "AppleMobileDeviceAccess",
code: 16,
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice content installation failed."]
)
}
return installedName
}.value
}
}
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) {

View File

@ -98,4 +98,15 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
NSError **error
);
FOUNDATION_EXPORT NSString * _Nullable
WMMInstallLocalDirectoryInConnectedDeviceApp(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *minecraftRootRelativePath,
NSString *collectionFolderName,
NSString *preferredDestinationName,
NSURL *sourceDirectoryURL,
NSError **error
);
NS_ASSUME_NONNULL_END

View File

@ -122,7 +122,11 @@ typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **val
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
typedef int (*AFCFileRefWriteFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, const void *buffer, uint32_t length);
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
typedef int (*AFCDirectoryCreateFn)(AFCConnectionRef connection, const char *path);
typedef int (*AFCRenamePathFn)(AFCConnectionRef connection, const char *sourcePath, const char *destinationPath);
typedef int (*AFCRemovePathFn)(AFCConnectionRef connection, const char *path);
typedef struct {
void *handle;
@ -159,7 +163,11 @@ typedef struct {
AFCKeyValueCloseFn AFCKeyValueClose;
AFCFileRefOpenFn AFCFileRefOpen;
AFCFileRefReadFn AFCFileRefRead;
AFCFileRefWriteFn AFCFileRefWrite;
AFCFileRefCloseFn AFCFileRefClose;
AFCDirectoryCreateFn AFCDirectoryCreate;
AFCRenamePathFn AFCRenamePath;
AFCRemovePathFn AFCRemovePath;
} WMMMobileDeviceFunctions;
typedef struct {
@ -237,7 +245,11 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
functions->AFCFileRefWrite = (AFCFileRefWriteFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefWrite");
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
functions->AFCDirectoryCreate = (AFCDirectoryCreateFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryCreate");
functions->AFCRenamePath = (AFCRenamePathFn)WMMLoadSymbol(frameworkHandle, "AFCRenamePath");
functions->AFCRemovePath = (AFCRemovePathFn)WMMLoadSymbol(frameworkHandle, "AFCRemovePath");
if (functions->AMDeviceNotificationSubscribe == NULL ||
functions->AMDeviceNotificationUnsubscribe == NULL ||
@ -1036,6 +1048,163 @@ static BOOL WMMCopyAFCTreeToLocalURL(
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
}
static BOOL WMMEnsureAFCDirectory(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSString *remotePath,
NSError **error
) {
NSMutableArray<NSString *> *existingEntries = nil;
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &existingEntries) == 0) {
return YES;
}
const int createStatus = functions->AFCDirectoryCreate(
afcConnection,
remotePath.fileSystemRepresentation
);
if (createStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
createStatus,
[NSString stringWithFormat:@"AFCDirectoryCreate failed for %@ (%d).", remotePath, createStatus]
);
}
return NO;
}
return YES;
}
static BOOL WMMCopyLocalFileToAFCPath(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSURL *localFileURL,
NSString *remotePath,
NSError **error
) {
AFCFileDescriptorRef fileDescriptor = NULL;
const int openStatus = functions->AFCFileRefOpen(
afcConnection,
remotePath.fileSystemRepresentation,
3,
&fileDescriptor
);
if (openStatus != 0 || fileDescriptor == NULL) {
if (error != NULL) {
*error = WMMMakeError(
openStatus,
[NSString stringWithFormat:@"AFCFileRefOpen for writing failed for %@ (%d).", remotePath, openStatus]
);
}
return NO;
}
NSFileHandle *handle = [NSFileHandle fileHandleForReadingFromURL:localFileURL error:error];
if (handle == nil) {
functions->AFCFileRefClose(afcConnection, fileDescriptor);
return NO;
}
BOOL success = YES;
while (true) {
@autoreleasepool {
NSData *chunk = [handle readDataOfLength:64 * 1024];
if (chunk.length == 0) {
break;
}
const int writeStatus = functions->AFCFileRefWrite(
afcConnection,
fileDescriptor,
chunk.bytes,
(uint32_t)chunk.length
);
if (writeStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
writeStatus,
[NSString stringWithFormat:@"AFCFileRefWrite failed for %@ (%d).", remotePath, writeStatus]
);
}
success = NO;
break;
}
}
}
[handle closeFile];
functions->AFCFileRefClose(afcConnection, fileDescriptor);
return success;
}
static BOOL WMMCopyLocalTreeToAFCPath(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSURL *localURL,
NSString *remotePath,
NSError **error
) {
NSNumber *isDirectory = nil;
if (![localURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:error]) {
return NO;
}
if (!isDirectory.boolValue) {
return WMMCopyLocalFileToAFCPath(functions, afcConnection, localURL, remotePath, error);
}
if (!WMMEnsureAFCDirectory(functions, afcConnection, remotePath, error)) {
return NO;
}
NSArray<NSURL *> *children = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:localURL
includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey]
options:0
error:error];
if (children == nil) {
return NO;
}
for (NSURL *childURL in children) {
NSNumber *isSymbolicLink = nil;
if (![childURL getResourceValue:&isSymbolicLink forKey:NSURLIsSymbolicLinkKey error:error]) {
return NO;
}
if (isSymbolicLink.boolValue) {
if (error != NULL) {
*error = WMMMakeError(17, [NSString stringWithFormat:@"Symbolic links cannot be installed: %@", childURL.path]);
}
return NO;
}
NSString *childRemotePath = [remotePath stringByAppendingPathComponent:childURL.lastPathComponent];
if (!WMMCopyLocalTreeToAFCPath(functions, afcConnection, childURL, childRemotePath, error)) {
return NO;
}
}
return YES;
}
static BOOL WMMRemoveAFCTree(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSString *remotePath
) {
NSMutableArray<NSString *> *entries = nil;
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries) == 0) {
for (NSString *entry in entries) {
if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) {
continue;
}
WMMRemoveAFCTree(
functions,
afcConnection,
[remotePath stringByAppendingPathComponent:entry]
);
}
}
return functions->AFCRemovePath(afcConnection, remotePath.fileSystemRepresentation) == 0;
}
static NSString *WMMNormalizedAFCPath(NSString *path) {
NSString *normalizedPath = path.length == 0 ? @"/" : path;
if (![normalizedPath hasPrefix:@"/"]) {
@ -2381,3 +2550,122 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
return success;
}
NSString * _Nullable
WMMInstallLocalDirectoryInConnectedDeviceApp(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *minecraftRootRelativePath,
NSString *collectionFolderName,
NSString *preferredDestinationName,
NSURL *sourceDirectoryURL,
NSError **error
) {
if (bundleIdentifier.length == 0 ||
minecraftRootRelativePath.length == 0 ||
collectionFolderName.length == 0 ||
preferredDestinationName.length == 0 ||
sourceDirectoryURL == nil ||
!sourceDirectoryURL.isFileURL) {
if (error != NULL) {
*error = WMMMakeError(16, @"A device, Minecraft path, collection, destination name, and local source directory are required.");
}
return nil;
}
WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) {
return nil;
}
if (functions.AFCFileRefWrite == NULL ||
functions.AFCDirectoryCreate == NULL ||
functions.AFCRenamePath == NULL ||
functions.AFCRemovePath == NULL) {
if (error != NULL) {
*error = WMMMakeError(17, @"This version of MobileDevice.framework does not provide the required AFC write operations.");
}
return nil;
}
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
functions.AMDeviceRelease(device);
return nil;
}
AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
&functions,
device,
bundleIdentifier,
&backingServiceConnection,
error
);
if (afcConnection == NULL) {
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
NSString *minecraftRoot = WMMNormalizedAFCPath(minecraftRootRelativePath);
NSString *stagingRoot = [minecraftRoot stringByAppendingPathComponent:@".world-manager-staging"];
NSString *stagedItemPath = [stagingRoot stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
NSString *collectionPath = [minecraftRoot stringByAppendingPathComponent:collectionFolderName];
BOOL success =
WMMEnsureAFCDirectory(&functions, afcConnection, stagingRoot, error) &&
WMMEnsureAFCDirectory(&functions, afcConnection, stagedItemPath, error) &&
WMMCopyLocalTreeToAFCPath(&functions, afcConnection, sourceDirectoryURL, stagedItemPath, error) &&
WMMEnsureAFCDirectory(&functions, afcConnection, collectionPath, error);
NSString *destinationName = nil;
if (success) {
NSMutableArray<NSString *> *existingEntries = nil;
const int listStatus = WMMReadAFCDirectory(&functions, afcConnection, collectionPath, &existingEntries);
if (listStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
listStatus,
[NSString stringWithFormat:@"Could not list the destination collection %@ (%d).", collectionPath, listStatus]
);
}
success = NO;
} else {
NSSet<NSString *> *existingNames = [NSSet setWithArray:existingEntries ?: @[]];
destinationName = preferredDestinationName;
NSUInteger suffix = 2;
while ([existingNames containsObject:destinationName]) {
destinationName = [NSString stringWithFormat:@"%@-%lu", preferredDestinationName, (unsigned long)suffix];
suffix += 1;
}
NSString *destinationPath = [collectionPath stringByAppendingPathComponent:destinationName];
const int renameStatus = functions.AFCRenamePath(
afcConnection,
stagedItemPath.fileSystemRepresentation,
destinationPath.fileSystemRepresentation
);
if (renameStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
renameStatus,
[NSString stringWithFormat:@"AFCRenamePath failed from %@ to %@ (%d).", stagedItemPath, destinationPath, renameStatus]
);
}
success = NO;
}
}
}
if (!success) {
WMMRemoveAFCTree(&functions, afcConnection, stagedItemPath);
destinationName = nil;
}
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
return destinationName;
}

View File

@ -365,6 +365,31 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
guard case .connectedDevice(_, let container) = source.origin else {
throw SourceAccessError.accessFailed(reason: "The selected source is not a connected device.")
}
let minecraftRoot = container.minecraftFolderRelativePath?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !minecraftRoot.isEmpty else {
throw SourceAccessError.accessFailed(reason: "The connected device is missing its Minecraft content path.")
}
let destinationName = try await AppleMobileDeviceAccess.installDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
minecraftRootRelativePath: minecraftRoot,
collectionFolderName: payload.contentType.collectionFolderName,
preferredDestinationName: payload.suggestedFolderName,
sourceDirectoryURL: payload.preparedDirectoryURL
)
return InstalledContentItem(
contentType: payload.contentType,
displayName: payload.displayName,
destinationName: destinationName
)
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
guard source.origin.kind == .connectedDevice else {
return

View File

@ -25,6 +25,7 @@ protocol SourceAccessMethod: Sendable {
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry]
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
}
@ -108,6 +109,12 @@ extension SourceAccessMethod {
return item.folderURL
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
_ = payload
_ = source
throw SourceAccessError.accessFailed(reason: "This source does not support installing Minecraft content.")
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
_ = source
}
@ -204,6 +211,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return try await accessMethod(for: source).materializeItem(for: item, in: source)
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
return try await accessMethod(for: source).install(payload, in: source)
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
await accessMethod(for: source).purgeCachedArtifacts(for: source)
}

View File

@ -133,6 +133,97 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
return item.folderURL
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
let sourceRootURL = try resolvedSourceRootURL(for: source)
let accessedSecurityScope = sourceRootURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
sourceRootURL.stopAccessingSecurityScopedResource()
}
}
let fileManager = FileManager.default
let contentRootURL = try resolvedContentRootURL(for: source, sourceRootURL: sourceRootURL)
let collectionURL = contentRootURL.appendingPathComponent(
payload.contentType.collectionFolderName,
isDirectory: true
)
let stagingRootURL = contentRootURL
.appendingPathComponent(".world-manager-staging", isDirectory: true)
let stagedItemURL = stagingRootURL
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
do {
try fileManager.copyItem(at: payload.preparedDirectoryURL, to: stagedItemURL)
try fileManager.createDirectory(at: collectionURL, withIntermediateDirectories: true)
let destinationURL = uniqueDestinationURL(
in: collectionURL,
preferredName: payload.suggestedFolderName,
fileManager: fileManager
)
try fileManager.moveItem(at: stagedItemURL, to: destinationURL)
return InstalledContentItem(
contentType: payload.contentType,
displayName: payload.displayName,
destinationName: destinationURL.lastPathComponent
)
} catch {
try? fileManager.removeItem(at: stagedItemURL)
throw error
}
}
nonisolated private func resolvedSourceRootURL(for source: MinecraftSource) throws -> URL {
guard case .localFolder(let bookmarkData) = source.origin, let bookmarkData else {
return source.folderURL
}
var isStale = false
return try URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
).standardizedFileURL
}
nonisolated private func resolvedContentRootURL(
for source: MinecraftSource,
sourceRootURL: URL
) throws -> URL {
let observedRoots = Set(source.rawItems.map {
$0.collectionRootURL.deletingLastPathComponent().standardizedFileURL
})
if let minimumDepth = observedRoots.map(\.pathComponents.count).min() {
let shallowestRoots = observedRoots.filter { $0.pathComponents.count == minimumDepth }
if shallowestRoots.count == 1, let observedRoot = shallowestRoots.first {
return observedRoot
}
if shallowestRoots.count > 1 {
throw SourceAccessError.accessFailed(
reason: "This source contains more than one Minecraft content root. Choose the specific com.mojang folder before importing."
)
}
}
return sourceRootURL
}
nonisolated private func uniqueDestinationURL(
in collectionURL: URL,
preferredName: String,
fileManager: FileManager
) -> URL {
var candidateURL = collectionURL.appendingPathComponent(preferredName, isDirectory: true)
var suffix = 2
while fileManager.fileExists(atPath: candidateURL.path) {
candidateURL = collectionURL.appendingPathComponent("\(preferredName)-\(suffix)", isDirectory: true)
suffix += 1
}
return candidateURL
}
nonisolated private func discoverItemsByReconcilingCache(
for source: MinecraftSource,
snapshot: SourceSnapshot,

View File

@ -7,6 +7,7 @@ import SwiftUI
struct ItemDetailColumnView: View {
let item: MinecraftContentItem?
let source: MinecraftSource?
let installationState: SourceInstallationState?
let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference]
@ -22,6 +23,7 @@ struct ItemDetailColumnView: View {
let exportAction: () -> Void
let revealAction: () -> Void
let shareAction: (NSView?) -> Void
let importAction: (MinecraftSource) -> Void
var body: some View {
Group {
@ -45,7 +47,13 @@ struct ItemDetailColumnView: View {
shareAction: shareAction
)
} else if showsSourceDetails, let source {
SourceDetailView(source: source)
SourceDetailView(
source: source,
installationState: installationState,
importAction: {
importAction(source)
}
)
} else {
Text("Select a world or pack to see details")
.foregroundStyle(.secondary)

View File

@ -20,13 +20,38 @@ struct SourceDetailView: View {
}
let source: MinecraftSource
let installationState: SourceInstallationState?
let importAction: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack(alignment: .firstTextBaseline) {
Text(source.displayName)
.font(.largeTitle.weight(.semibold))
Spacer()
Button("Import...", action: importAction)
.disabled(
source.availability != .available ||
!source.capabilities.canInstallItems ||
installationState != nil
)
}
if let installationState {
VStack(alignment: .leading, spacing: 8) {
ProgressView(
value: Double(installationState.completedCount),
total: Double(max(installationState.totalCount, 1))
)
Text(installationState.status)
.appTextStyle(.supporting)
}
.appDetailSectionCard()
}
if showsStatusSection {
sourceStatusSection
}

View File

@ -41,6 +41,7 @@ struct ItemListColumnView<MenuContent: View>: View {
let searchPrompt: String
let chooseFolderAction: () -> Void
let dropAction: ([NSItemProvider]) -> Bool
let dragProvider: (MinecraftContentItem) -> NSItemProvider
let itemContextMenu: (MinecraftContentItem) -> MenuContent
var body: some View {
@ -55,6 +56,14 @@ struct ItemListColumnView<MenuContent: View>: View {
List(items, selection: $selectedItemID) { item in
ContentRowView(item: item)
.tag(item.id)
.onDrag {
dragProvider(item)
}
.simultaneousGesture(
TapGesture().onEnded {
selectedItemID = item.id
}
)
.contextMenu {
itemContextMenu(item)
}

View File

@ -262,6 +262,9 @@ struct SidebarColumnPreviewContainer: View {
addConnectedDeviceAction: { _ in },
rescanSourceAction: { _ in },
removeSourceAction: { _ in },
importSourceAction: { _ in },
importDropAction: { _, _ in false },
installationState: { _ in nil },
filters: { source in
let allFilter = SidebarFilter(
title: "All Content",
@ -331,6 +334,7 @@ struct ItemListColumnPreviewContainer: View {
searchPrompt: "Search Worlds",
chooseFolderAction: {},
dropAction: { _ in false },
dragProvider: { _ in NSItemProvider() },
itemContextMenu: { item in
Button("Reveal \(item.displayName)") {}
}
@ -345,6 +349,7 @@ struct ItemDetailColumnPreviewContainer: View {
ItemDetailColumnView(
item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource,
installationState: nil,
showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -359,7 +364,8 @@ struct ItemDetailColumnPreviewContainer: View {
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
exportAction: {},
revealAction: {},
shareAction: { _ in }
shareAction: { _ in },
importAction: { _ in }
)
}
}

View File

@ -14,6 +14,7 @@ struct ContentView: View {
@State private var isDropTargeted = false
@State private var isPerformingItemAction = false
@State private var isShowingDeviceSourceSheet = false
@State private var importErrorMessage: String?
@State private var sortMode: ItemSortMode = .name
@State private var directoryPreviewContents: [DirectoryEntry] = []
@State private var showsProjectionLoadingState = false
@ -87,6 +88,11 @@ struct ContentView: View {
removeSourceAction: { source in
removeSource(source.id)
},
importSourceAction: importIntoSource(_:),
importDropAction: handleImportDrop(on:providers:),
installationState: { source in
library.installationStateBySourceID[source.id]
},
filters: sidebarFilters(for:)
)
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
@ -109,6 +115,7 @@ struct ContentView: View {
searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:),
dragProvider: dragProvider(for:),
itemContextMenu: itemContextMenu(for:)
)
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
@ -116,6 +123,7 @@ struct ContentView: View {
ItemDetailColumnView(
item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource,
installationState: resolvedCurrentSource.flatMap { library.installationStateBySourceID[$0.id] },
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -148,7 +156,8 @@ struct ContentView: View {
}
shareItem(item, from: anchorView)
}
},
importAction: importIntoSource(_:)
)
.frame(minWidth: 450)
}
@ -169,6 +178,23 @@ struct ContentView: View {
}
)
}
.alert(
"Import Failed",
isPresented: Binding(
get: { importErrorMessage != nil },
set: { isPresented in
if !isPresented {
importErrorMessage = nil
}
}
)
) {
Button("OK") {
importErrorMessage = nil
}
} message: {
Text(importErrorMessage ?? "The Minecraft content could not be imported.")
}
.task {
AppTerminationCoordinator.shared.register(library: library)
}
@ -526,6 +552,166 @@ struct ContentView: View {
}
}
private func importIntoSource(_ source: MinecraftSource) {
guard source.availability == .available, source.capabilities.canInstallItems else {
return
}
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.title = "Import Minecraft Content into \(source.displayName)"
panel.prompt = "Import"
panel.allowedContentTypes = [
.minecraftWorld,
.minecraftPack,
.minecraftTemplate,
.minecraftAddon
]
guard panel.runModal() == .OK else {
return
}
Task {
await performImport(packageURLs: panel.urls, into: source.id)
}
}
private func handleImportDrop(
on source: MinecraftSource,
providers: [NSItemProvider]
) -> Bool {
guard
source.availability == .available,
source.capabilities.canInstallItems,
providers.contains(where: isSupportedImportProvider(_:))
else {
return false
}
Task {
let materializedDrop = await materializeDroppedPackages(from: providers)
defer {
try? FileManager.default.removeItem(at: materializedDrop.temporaryRootURL)
}
guard !materializedDrop.packageURLs.isEmpty else {
await MainActor.run {
importErrorMessage = "The dropped items did not contain a supported .mcworld, .mcpack, .mctemplate, or .mcaddon file."
}
return
}
await performImport(packageURLs: materializedDrop.packageURLs, into: source.id)
}
return true
}
@MainActor
private func performImport(packageURLs: [URL], into sourceID: URL) async {
do {
_ = try await library.installPackages(at: packageURLs, into: sourceID)
selectedSidebarSelection = .source(sourceID: sourceID)
selectedItemID = nil
} catch {
importErrorMessage = error.localizedDescription
}
}
private func isSupportedImportProvider(_ provider: NSItemProvider) -> Bool {
importTypeIdentifiers.contains {
provider.hasItemConformingToTypeIdentifier($0)
}
}
private var importTypeIdentifiers: [String] {
[
UTType.minecraftWorld.identifier,
UTType.minecraftPack.identifier,
UTType.minecraftTemplate.identifier,
UTType.minecraftAddon.identifier,
UTType.fileURL.identifier
]
}
private func materializeDroppedPackages(
from providers: [NSItemProvider]
) async -> (packageURLs: [URL], temporaryRootURL: URL) {
let temporaryRootURL = FileManager.default.temporaryDirectory
.appendingPathComponent("MinecraftImportDrop", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true)
var packageURLs: [URL] = []
for provider in providers where isSupportedImportProvider(provider) {
if let packageURL = await materializeDroppedPackage(
from: provider,
in: temporaryRootURL
) {
packageURLs.append(packageURL)
}
}
return (packageURLs, temporaryRootURL)
}
private func materializeDroppedPackage(
from provider: NSItemProvider,
in temporaryRootURL: URL
) async -> URL? {
let packageDefinitions = MinecraftPackageTypes.all
if let definition = packageDefinitions.first(where: {
provider.hasItemConformingToTypeIdentifier($0.utTypeIdentifier)
}) {
return await withCheckedContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: definition.utTypeIdentifier) { url, _ in
guard let url else {
continuation.resume(returning: nil)
return
}
let baseName = provider.suggestedName.map {
URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent
} ?? UUID().uuidString
let destinationURL = temporaryRootURL
.appendingPathComponent(baseName)
.appendingPathExtension(definition.pathExtension)
do {
try FileManager.default.copyItem(at: url, to: destinationURL)
continuation.resume(returning: destinationURL)
} catch {
continuation.resume(returning: nil)
}
}
}
}
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
return nil
}
return await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
guard
let data,
let sourceURL = NSURL(
absoluteURLWithDataRepresentation: data,
relativeTo: nil
) as URL?,
MinecraftPackageInspector.supportedPathExtensions.contains(
sourceURL.pathExtension.lowercased()
)
else {
continuation.resume(returning: nil)
return
}
let destinationURL = temporaryRootURL.appendingPathComponent(sourceURL.lastPathComponent)
do {
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
continuation.resume(returning: destinationURL)
} catch {
continuation.resume(returning: nil)
}
}
}
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
import UniformTypeIdentifiers
enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
@ -33,6 +34,9 @@ struct SourcesSidebarView: View {
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void
let removeSourceAction: (MinecraftSource) -> Void
let importSourceAction: (MinecraftSource) -> Void
let importDropAction: (MinecraftSource, [NSItemProvider]) -> Bool
let installationState: (MinecraftSource) -> SourceInstallationState?
let filters: (MinecraftSource) -> [SidebarFilter]
var body: some View {
@ -81,8 +85,12 @@ struct SourcesSidebarView: View {
SourceHeaderRow(
source: source,
installationState: installationState(source),
onSelect: {
selection = .source(sourceID: source.id)
},
dropAction: { providers in
importDropAction(source, providers)
}
)
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
@ -93,6 +101,11 @@ struct SourcesSidebarView: View {
rescanSourceAction(source)
}
Button("Import into \"\(source.displayName)\"...") {
importSourceAction(source)
}
.disabled(source.availability != .available || !source.capabilities.canInstallItems)
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
@ -150,7 +163,10 @@ private struct SidebarSourcesSectionHeaderView: View {
private struct SourceHeaderRow: View {
let source: MinecraftSource
let installationState: SourceInstallationState?
let onSelect: () -> Void
let dropAction: ([NSItemProvider]) -> Bool
@State private var isDropTargeted = false
var body: some View {
HStack(spacing: 8) {
@ -179,8 +195,23 @@ private struct SourceHeaderRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 5)
.padding(.vertical, 6)
.background(
isDropTargeted ? Color.accentColor.opacity(0.16) : Color.clear,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.onDrop(
of: [
UTType.fileURL.identifier,
UTType.minecraftWorld.identifier,
UTType.minecraftPack.identifier,
UTType.minecraftTemplate.identifier,
UTType.minecraftAddon.identifier
],
isTargeted: $isDropTargeted,
perform: dropAction
)
}
private var connection: DeviceConnection? {
@ -224,12 +255,23 @@ private struct SourceHeaderRow: View {
}
private var showsStatusAccessory: Bool {
source.isScanning
source.isScanning || installationState != nil
}
@ViewBuilder
private var statusAccessory: some View {
if source.isScanning {
if let installationState {
if installationState.totalCount > 0 {
CircularScanProgressView(
progress: Double(installationState.completedCount) / Double(installationState.totalCount)
)
.help(installationState.status)
} else {
ProgressView()
.appActivityIndicatorStyle(.small)
.help(installationState.status)
}
} else if source.isScanning {
if let scanProgress = source.scanProgress {
CircularScanProgressView(progress: scanProgress)
} else {

View File

@ -771,6 +771,119 @@ struct World_Manager_for_MinecraftTests {
}
}
@Test func packageInstallerPreparesCompleteWorldContents() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let worldURL = workingURL.appendingPathComponent("World", isDirectory: true)
let archiveURL = workingURL.appendingPathComponent("World.mcworld", isDirectory: false)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(
at: worldURL.appendingPathComponent("db", isDirectory: true),
withIntermediateDirectories: true
)
try Data([1, 2, 3]).write(to: worldURL.appendingPathComponent("level.dat"))
try Data([4, 5, 6]).write(to: worldURL.appendingPathComponent("db/chunk.bin"))
try makeArchive(from: worldURL, to: archiveURL)
let plan = try MinecraftPackageInstaller.preparePackage(at: archiveURL)
defer { plan.cleanup() }
#expect(plan.payloads.count == 1)
#expect(plan.payloads[0].contentType == .world)
#expect(
fileManager.fileExists(
atPath: plan.payloads[0].preparedDirectoryURL.appendingPathComponent("db/chunk.bin").path
)
)
}
@Test func packageInstallerExpandsNestedAddonPackages() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let behaviorURL = workingURL.appendingPathComponent("Behavior", isDirectory: true)
let resourceURL = workingURL.appendingPathComponent("Resource", isDirectory: true)
let addonRootURL = workingURL.appendingPathComponent("Addon", isDirectory: true)
let behaviorArchiveURL = addonRootURL.appendingPathComponent("Behavior.mcpack")
let resourceArchiveURL = addonRootURL.appendingPathComponent("Resource.mcpack")
let addonURL = workingURL.appendingPathComponent("Combined.mcaddon")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: behaviorURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: resourceURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: addonRootURL, withIntermediateDirectories: true)
try testPackManifest(name: "Behavior", uuid: "11111111-1111-1111-1111-111111111111", moduleType: "data")
.write(to: behaviorURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
try testPackManifest(name: "Resource", uuid: "22222222-2222-2222-2222-222222222222", moduleType: "resources")
.write(to: resourceURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
try makeArchive(from: behaviorURL, to: behaviorArchiveURL)
try makeArchive(from: resourceURL, to: resourceArchiveURL)
try makeArchive(from: addonRootURL, to: addonURL)
let plan = try MinecraftPackageInstaller.preparePackage(at: addonURL)
defer { plan.cleanup() }
#expect(Set(plan.payloads.map(\.contentType)) == [.behaviorPack, .resourcePack])
}
@Test func localFolderInstallerUsesUniqueAdditiveDestinations() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let sourceWorldURL = workingURL.appendingPathComponent("PreparedWorld", isDirectory: true)
let destinationRootURL = workingURL.appendingPathComponent("Destination", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: sourceWorldURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
try Data([1]).write(to: sourceWorldURL.appendingPathComponent("level.dat"))
let plan = try MinecraftPackageInstaller.prepareDirectory(at: sourceWorldURL, contentType: .world)
defer { plan.cleanup() }
let source = MinecraftSource(folderURL: destinationRootURL, availability: .available)
let access = LocalFolderSourceAccess()
let first = try await access.install(plan.payloads[0], in: source)
let second = try await access.install(plan.payloads[0], in: source)
#expect(first.destinationName != second.destinationName)
#expect(
fileManager.fileExists(
atPath: destinationRootURL
.appendingPathComponent("minecraftWorlds/\(first.destinationName)/level.dat")
.path
)
)
#expect(
fileManager.fileExists(
atPath: destinationRootURL
.appendingPathComponent("minecraftWorlds/\(second.destinationName)/level.dat")
.path
)
)
}
@Test func zipReaderRejectsParentTraversalEntry() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let archiveURL = workingURL.appendingPathComponent("Unsafe.mcworld")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
try makeStoredArchive(entries: [("../escape.txt", Data("escape".utf8))], to: archiveURL)
do {
_ = try ZipArchiveReader(url: archiveURL)
Issue.record("Expected the unsafe archive path to be rejected.")
} catch let error as ZipArchiveReaderError {
switch error {
case .unsafeEntryPath(let path):
#expect(path == "../escape.txt")
default:
Issue.record("Expected unsafeEntryPath but received \(error).")
}
}
}
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
@ -1122,6 +1235,24 @@ struct World_Manager_for_MinecraftTests {
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
}
@Test func liveScanStatusCountsItemsRemainingDuringSizeCalculation() async throws {
var source = MinecraftSource(
folderURL: URL(fileURLWithPath: "/tmp/test-source", isDirectory: true)
)
source.displayName = "Test Source"
source.isScanning = true
source.indexedItemCount = 22
source.previewLoadedCount = 22
source.sizeLoadedCount = 12
source.scanProgress = 0.88
source.scanStatus = "Calculating sizes"
#expect(
SourcePresentation.liveScanStatusTitle(for: source)
== "Calculating sizes for 10 of 22 items..."
)
}
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
let service = ScanNotificationService()
@ -1251,6 +1382,82 @@ private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throw
}
}
private func testPackManifest(name: String, uuid: String, moduleType: String) -> String {
"""
{
"format_version": 2,
"header": {
"name": "\(name)",
"description": "\(name)",
"uuid": "\(uuid)",
"version": [1, 0, 0]
},
"modules": [
{
"type": "\(moduleType)",
"uuid": "\(UUID().uuidString)",
"version": [1, 0, 0]
}
]
}
"""
}
private func makeStoredArchive(entries: [(String, Data)], to archiveURL: URL) throws {
var archive = Data()
var centralDirectory = Data()
for (path, contents) in entries {
let pathData = Data(path.utf8)
let localHeaderOffset = UInt32(archive.count)
appendLE(UInt32(0x04034b50), to: &archive)
appendLE(UInt16(20), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt32(0), to: &archive)
appendLE(UInt32(contents.count), to: &archive)
appendLE(UInt32(contents.count), to: &archive)
appendLE(UInt16(pathData.count), to: &archive)
appendLE(UInt16(0), to: &archive)
archive.append(pathData)
archive.append(contents)
appendLE(UInt32(0x02014b50), to: &centralDirectory)
appendLE(UInt16(20), to: &centralDirectory)
appendLE(UInt16(20), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt32(0), to: &centralDirectory)
appendLE(UInt32(contents.count), to: &centralDirectory)
appendLE(UInt32(contents.count), to: &centralDirectory)
appendLE(UInt16(pathData.count), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt32(0), to: &centralDirectory)
appendLE(localHeaderOffset, to: &centralDirectory)
centralDirectory.append(pathData)
}
let centralDirectoryOffset = UInt32(archive.count)
archive.append(centralDirectory)
appendLE(UInt32(0x06054b50), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(entries.count), to: &archive)
appendLE(UInt16(entries.count), to: &archive)
appendLE(UInt32(centralDirectory.count), to: &archive)
appendLE(centralDirectoryOffset, to: &archive)
appendLE(UInt16(0), to: &archive)
try archive.write(to: archiveURL, options: .atomic)
}
private enum ArchiveTestError: LocalizedError {
case failedToCreateArchive(String)

View File

@ -2,7 +2,7 @@
## Summary
World Manager can browse Minecraft Bedrock content from a trusted iPhone or iPad on macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
World Manager can browse and install Minecraft Bedrock content on a trusted iPhone or iPad from macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
@ -87,6 +87,18 @@ During materialization:
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
- Temporary materialized folders are treated as disposable.
During installation:
- Package contents are fully extracted and validated on the Mac.
- The bridge creates an operation directory under `Documents/games/com.mojang/.world-manager-staging`.
- Files are uploaded through `AFCFileRefWrite`.
- The destination collection is created if needed.
- `AFCRenamePath` moves the completed staging directory into the appropriate collection.
- A unique destination directory is selected, so an existing world or folder is not overwritten.
- Failed staging trees are removed recursively through AFC.
The write symbols are loaded opportunistically. Their absence prevents installation but does not disable read-only device access.
## Persistence
Connected-device sources are persisted in the SQLite source cache with:
@ -132,3 +144,6 @@ These commands require a trusted connected device and generally need to run outs
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
- Device access requires trust, unlock state, and a vendable app container.
- Connected-device export/reveal operations may be slower than local folder operations because they materialize remote content on demand.
- Device writes are serialized with scans and exports for the same device.
- Multi-item imports can be partially completed if the device disconnects between items. The app reports the completed count and rescans the destination when it reconnects.
- Pack replacement and removal are intentionally not implemented in the additive first version.