From e5345b91d8b0ec68f0f23ed675ba55a2e520c47f Mon Sep 17 00:00:00 2001 From: John Burwell Date: Sun, 2 Aug 2026 19:57:14 -0500 Subject: [PATCH] Update for async export and refresh --- README.md | 12 +- .../MinecraftPackagePreviewExtension.xcscheme | 1 + ...inecraftPackageThumbnailExtension.xcscheme | 1 + .../Models/Sources/SourceCapabilities.swift | 7 +- .../Import/MinecraftPackageInstaller.swift | 361 ++++++++++++++++++ .../MinecraftPackageInspector.swift | 4 +- .../ArchiveInspection/ZipArchiveReader.swift | 33 +- .../Services/Sources/Core/SourceLibrary.swift | 138 +++++++ .../Sources/Core/SourcePresentation.swift | 3 +- .../Scanning/SourceScanExecution.swift | 3 +- .../AppleMobileDeviceAccess.swift | 31 ++ .../AppleMobileDeviceBridge.h | 11 + .../AppleMobileDeviceBridge.m | 288 ++++++++++++++ .../AppleMobileDeviceSourceAccess.swift | 25 ++ .../Core/SourceAccessCoordinator.swift | 11 + .../LocalFolder/LocalFolderSourceAccess.swift | 91 +++++ .../UI/Detail/ItemDetailColumnViews.swift | 10 +- .../UI/Detail/SourceDetailView.swift | 29 +- .../UI/List/ItemListColumnViews.swift | 9 + .../UI/Preview/PreviewFixtures.swift | 8 +- .../UI/Root/ContentView.swift | 188 ++++++++- .../UI/Sidebar/SidebarColumnViews.swift | 46 ++- .../World_Manager_for_MinecraftTests.swift | 207 ++++++++++ docs/ios-device-access.md | 17 +- 24 files changed, 1517 insertions(+), 17 deletions(-) create mode 100644 World Manager for Minecraft/Services/AppSupport/Import/MinecraftPackageInstaller.swift diff --git a/README.md b/README.md index 6a29944..10ec4dd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme index 7816517..13d9776 100644 --- a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme +++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme @@ -74,6 +74,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme index bf51027..1e8af39 100644 --- a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme +++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme @@ -73,6 +73,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/World Manager for Minecraft/Models/Sources/SourceCapabilities.swift b/World Manager for Minecraft/Models/Sources/SourceCapabilities.swift index 031de38..885560d 100644 --- a/World Manager for Minecraft/Models/Sources/SourceCapabilities.swift +++ b/World Manager for Minecraft/Models/Sources/SourceCapabilities.swift @@ -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 ) } diff --git a/World Manager for Minecraft/Services/AppSupport/Import/MinecraftPackageInstaller.swift b/World Manager for Minecraft/Services/AppSupport/Import/MinecraftPackageInstaller.swift new file mode 100644 index 0000000..4b3b30e --- /dev/null +++ b/World Manager for Minecraft/Services/AppSupport/Import/MinecraftPackageInstaller.swift @@ -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() + 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 + } + } + } +} diff --git a/World Manager for Minecraft/Services/ArchiveInspection/MinecraftPackageInspector.swift b/World Manager for Minecraft/Services/ArchiveInspection/MinecraftPackageInspector.swift index e432374..c06cfb2 100644 --- a/World Manager for Minecraft/Services/ArchiveInspection/MinecraftPackageInspector.swift +++ b/World Manager for Minecraft/Services/ArchiveInspection/MinecraftPackageInspector.swift @@ -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"] diff --git a/World Manager for Minecraft/Services/ArchiveInspection/ZipArchiveReader.swift b/World Manager for Minecraft/Services/ArchiveInspection/ZipArchiveReader.swift index e21d00c..2b5161c 100644 --- a/World Manager for Minecraft/Services/ArchiveInspection/ZipArchiveReader.swift +++ b/World Manager for Minecraft/Services/ArchiveInspection/ZipArchiveReader.swift @@ -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 } } diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 2f73606..054a3c5 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -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] = [:] private var automaticSyncTasks: [URL: Task] = [:] @@ -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() diff --git a/World Manager for Minecraft/Services/Sources/Core/SourcePresentation.swift b/World Manager for Minecraft/Services/Sources/Core/SourcePresentation.swift index ded3b2c..dbc36e9 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourcePresentation.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourcePresentation.swift @@ -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: diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift index 9381f36..5fac3f2 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift @@ -750,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 diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift index a23717c..7a4cc5a 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift @@ -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) { diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h index 6b0c4b6..d9312e5 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h @@ -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 diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m index 93e4f13..712356d 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m @@ -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 *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 *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 *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 *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 *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; +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift index f02245d..779fa0e 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -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 diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index 543ff96..dad5391 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -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) } diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index d9a04fa..3c9d810 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -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, diff --git a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift index 49b02e5..42bcdaa 100644 --- a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift @@ -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) diff --git a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift index 2cdd868..82f1f5d 100644 --- a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift @@ -20,12 +20,37 @@ struct SourceDetailView: View { } let source: MinecraftSource + let installationState: SourceInstallationState? + let importAction: () -> Void var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(source.displayName) - .font(.largeTitle.weight(.semibold)) + 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 diff --git a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift index 6967c0e..900c852 100644 --- a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift +++ b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift @@ -41,6 +41,7 @@ struct ItemListColumnView: 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: 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) } diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index ad4b8ba..f7b8676 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -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 } ) } } diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 04c540a..9bc4d48 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -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) } diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index b312501..12172ec 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -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 { diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index a1b1cb7..9d0f382 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -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: ¢ralDirectory) + appendLE(UInt16(20), to: ¢ralDirectory) + appendLE(UInt16(20), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt32(0), to: ¢ralDirectory) + appendLE(UInt32(contents.count), to: ¢ralDirectory) + appendLE(UInt32(contents.count), to: ¢ralDirectory) + appendLE(UInt16(pathData.count), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt16(0), to: ¢ralDirectory) + appendLE(UInt32(0), to: ¢ralDirectory) + appendLE(localHeaderOffset, to: ¢ralDirectory) + 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) diff --git a/docs/ios-device-access.md b/docs/ios-device-access.md index d9bcfd8..c377b61 100644 --- a/docs/ios-device-access.md +++ b/docs/ios-device-access.md @@ -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.