Compare commits
No commits in common. "e5345b91d8b0ec68f0f23ed675ba55a2e520c47f" and "3a97ae0d536a0a1fece21e875dda6cd221e89b90" have entirely different histories.
e5345b91d8
...
3a97ae0d53
12
README.md
12
README.md
@ -17,8 +17,6 @@ This thing is called "World Manager," and it happens to pertain to someone else'
|
|||||||
- Detect worlds, resource packs, behavior packs, and world templates.
|
- Detect worlds, resource packs, behavior packs, and world templates.
|
||||||
- Inspect package metadata, pack relationships, icons, versions, UUIDs, and basic world facts.
|
- 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.
|
- 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.
|
- Preview and thumbnail supported Minecraft package files with Quick Look extensions.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@ -45,14 +43,6 @@ 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.
|
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
|
## 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.
|
- 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.
|
||||||
@ -107,7 +97,7 @@ See [docs/ios-device-access.md](docs/ios-device-access.md) for the current devic
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,6 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -73,7 +73,6 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -7,19 +7,16 @@ nonisolated struct SourceCapabilities: Hashable, Sendable, Codable {
|
|||||||
var canScan: Bool = true
|
var canScan: Bool = true
|
||||||
var canMaterializeItems: Bool = true
|
var canMaterializeItems: Bool = true
|
||||||
var canExportPortablePackages: Bool = true
|
var canExportPortablePackages: Bool = true
|
||||||
var canInstallItems: Bool = false
|
|
||||||
|
|
||||||
static let localFolder = SourceCapabilities(
|
static let localFolder = SourceCapabilities(
|
||||||
canScan: true,
|
canScan: true,
|
||||||
canMaterializeItems: true,
|
canMaterializeItems: true,
|
||||||
canExportPortablePackages: true,
|
canExportPortablePackages: true
|
||||||
canInstallItems: true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
static let connectedDevice = SourceCapabilities(
|
static let connectedDevice = SourceCapabilities(
|
||||||
canScan: true,
|
canScan: true,
|
||||||
canMaterializeItems: true,
|
canMaterializeItems: true,
|
||||||
canExportPortablePackages: true,
|
canExportPortablePackages: true
|
||||||
canInstallItems: true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,361 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -147,7 +147,7 @@ enum MinecraftPackageInspector {
|
|||||||
return contentRootURL
|
return contentRootURL
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func resolvedContentRootPath(
|
nonisolated private static func resolvedContentRootPath(
|
||||||
in entries: [ZipArchiveEntry],
|
in entries: [ZipArchiveEntry],
|
||||||
archivePathExtension: String
|
archivePathExtension: String
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
@ -181,7 +181,7 @@ enum MinecraftPackageInspector {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
||||||
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
||||||
|
|
||||||
let worldMarkers = ["level.dat", "levelname.txt"]
|
let worldMarkers = ["level.dat", "levelname.txt"]
|
||||||
|
|||||||
@ -11,14 +11,12 @@ nonisolated struct ZipArchiveEntry: Sendable, Hashable {
|
|||||||
let uncompressedSize: UInt32
|
let uncompressedSize: UInt32
|
||||||
let localHeaderOffset: UInt32
|
let localHeaderOffset: UInt32
|
||||||
let isDirectory: Bool
|
let isDirectory: Bool
|
||||||
let isSymbolicLink: Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated enum ZipArchiveReaderError: LocalizedError {
|
nonisolated enum ZipArchiveReaderError: LocalizedError {
|
||||||
case invalidArchive
|
case invalidArchive
|
||||||
case unsupportedCompressionMethod(UInt16)
|
case unsupportedCompressionMethod(UInt16)
|
||||||
case unsupportedFeatures(String)
|
case unsupportedFeatures(String)
|
||||||
case unsafeEntryPath(String)
|
|
||||||
case entryNotFound(String)
|
case entryNotFound(String)
|
||||||
case decompressionFailed(Int32)
|
case decompressionFailed(Int32)
|
||||||
|
|
||||||
@ -30,8 +28,6 @@ nonisolated enum ZipArchiveReaderError: LocalizedError {
|
|||||||
return "Unsupported ZIP compression method: \(method)."
|
return "Unsupported ZIP compression method: \(method)."
|
||||||
case .unsupportedFeatures(let message):
|
case .unsupportedFeatures(let message):
|
||||||
return message
|
return message
|
||||||
case .unsafeEntryPath(let path):
|
|
||||||
return "The ZIP archive contains an unsafe entry path: \(path)"
|
|
||||||
case .entryNotFound(let path):
|
case .entryNotFound(let path):
|
||||||
return "ZIP entry not found: \(path)"
|
return "ZIP entry not found: \(path)"
|
||||||
case .decompressionFailed(let code):
|
case .decompressionFailed(let code):
|
||||||
@ -45,7 +41,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let entries: [ZipArchiveEntry]
|
let entries: [ZipArchiveEntry]
|
||||||
|
|
||||||
init(url: URL) throws {
|
init(url: URL) throws {
|
||||||
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
|
self.data = try Data(contentsOf: url)
|
||||||
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,9 +78,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
|
|
||||||
switch entry.compressionMethod {
|
switch entry.compressionMethod {
|
||||||
case 0:
|
case 0:
|
||||||
guard compressedData.count == Int(entry.uncompressedSize) else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
return compressedData
|
return compressedData
|
||||||
case 8:
|
case 8:
|
||||||
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
|
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
|
||||||
@ -122,7 +115,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
|
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
|
||||||
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
|
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
|
||||||
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
|
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
|
||||||
let externalAttributes = data.readUInt32LE(at: offset + 38)
|
|
||||||
|
|
||||||
let filenameStart = offset + 46
|
let filenameStart = offset + 46
|
||||||
let filenameEnd = filenameStart + filenameLength
|
let filenameEnd = filenameStart + filenameLength
|
||||||
@ -135,9 +127,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
throw ZipArchiveReaderError.invalidArchive
|
throw ZipArchiveReaderError.invalidArchive
|
||||||
}
|
}
|
||||||
|
|
||||||
try validateEntryPath(filename)
|
|
||||||
let normalizedPath = Self.normalizedPath(filename)
|
let normalizedPath = Self.normalizedPath(filename)
|
||||||
let unixMode = UInt16((externalAttributes >> 16) & 0xffff)
|
|
||||||
entries.append(
|
entries.append(
|
||||||
ZipArchiveEntry(
|
ZipArchiveEntry(
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
@ -145,8 +135,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
compressedSize: compressedSize,
|
compressedSize: compressedSize,
|
||||||
uncompressedSize: uncompressedSize,
|
uncompressedSize: uncompressedSize,
|
||||||
localHeaderOffset: localHeaderOffset,
|
localHeaderOffset: localHeaderOffset,
|
||||||
isDirectory: normalizedPath.hasSuffix("/"),
|
isDirectory: normalizedPath.hasSuffix("/")
|
||||||
isSymbolicLink: unixMode & 0o170000 == 0o120000
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -189,18 +178,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
return joined
|
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 {
|
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
|
||||||
if data.isEmpty {
|
if data.isEmpty {
|
||||||
return Data()
|
return Data()
|
||||||
@ -251,17 +228,11 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
|
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
|
||||||
if producedByteCount > 0 {
|
if producedByteCount > 0 {
|
||||||
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
|
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
|
||||||
guard output.count <= expectedSize else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} while status != Z_STREAM_END
|
} while status != Z_STREAM_END
|
||||||
}
|
}
|
||||||
|
|
||||||
guard output.count == expectedSize else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,6 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||||
@Published var isRestoringPersistedSources = true
|
@Published var isRestoringPersistedSources = true
|
||||||
@Published var installationStateBySourceID: [URL: SourceInstallationState] = [:]
|
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
||||||
@ -251,143 +250,6 @@ 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) {
|
func removeSource(withID sourceID: URL) {
|
||||||
let removedSource = source(withID: sourceID)
|
let removedSource = source(withID: sourceID)
|
||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
|
|||||||
@ -120,8 +120,7 @@ enum SourcePresentation {
|
|||||||
case .discovering, .metadata, .previews:
|
case .discovering, .metadata, .previews:
|
||||||
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
|
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
|
||||||
case .sizing:
|
case .sizing:
|
||||||
let remainingCount = max(source.indexedItemCount - source.sizeLoadedCount, 0)
|
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
|
||||||
return "Calculating sizes for \(remainingCount) of \(source.indexedItemCount) items..."
|
|
||||||
case .completed:
|
case .completed:
|
||||||
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
|
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
|
||||||
case .idle:
|
case .idle:
|
||||||
|
|||||||
@ -227,6 +227,23 @@ enum SourceScanExecutor {
|
|||||||
}
|
}
|
||||||
host.persistSourceIfAvailable(withID: sourceID)
|
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()
|
let sizeQueue = EnrichmentWorkQueue()
|
||||||
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
|
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
@ -305,6 +322,52 @@ 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(
|
private static func finishScan(
|
||||||
sourceID: URL,
|
sourceID: URL,
|
||||||
source: MinecraftSource,
|
source: MinecraftSource,
|
||||||
@ -750,8 +813,7 @@ private actor SourceIndexActor {
|
|||||||
} else if sizeLoadedCount == 0 {
|
} else if sizeLoadedCount == 0 {
|
||||||
scanStatus = "Preparing size calculations..."
|
scanStatus = "Preparing size calculations..."
|
||||||
} else {
|
} else {
|
||||||
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
|
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
|
||||||
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scanStatus = indexedItemCount == 0
|
scanStatus = indexedItemCount == 0
|
||||||
|
|||||||
@ -177,37 +177,6 @@ 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] {
|
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
||||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
|||||||
@ -98,15 +98,4 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
|||||||
NSError **error
|
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
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@ -122,11 +122,7 @@ typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **val
|
|||||||
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
||||||
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
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 (*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 (*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 {
|
typedef struct {
|
||||||
void *handle;
|
void *handle;
|
||||||
@ -163,11 +159,7 @@ typedef struct {
|
|||||||
AFCKeyValueCloseFn AFCKeyValueClose;
|
AFCKeyValueCloseFn AFCKeyValueClose;
|
||||||
AFCFileRefOpenFn AFCFileRefOpen;
|
AFCFileRefOpenFn AFCFileRefOpen;
|
||||||
AFCFileRefReadFn AFCFileRefRead;
|
AFCFileRefReadFn AFCFileRefRead;
|
||||||
AFCFileRefWriteFn AFCFileRefWrite;
|
|
||||||
AFCFileRefCloseFn AFCFileRefClose;
|
AFCFileRefCloseFn AFCFileRefClose;
|
||||||
AFCDirectoryCreateFn AFCDirectoryCreate;
|
|
||||||
AFCRenamePathFn AFCRenamePath;
|
|
||||||
AFCRemovePathFn AFCRemovePath;
|
|
||||||
} WMMMobileDeviceFunctions;
|
} WMMMobileDeviceFunctions;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@ -245,11 +237,7 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
|||||||
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
||||||
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
||||||
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
||||||
functions->AFCFileRefWrite = (AFCFileRefWriteFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefWrite");
|
|
||||||
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
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 ||
|
if (functions->AMDeviceNotificationSubscribe == NULL ||
|
||||||
functions->AMDeviceNotificationUnsubscribe == NULL ||
|
functions->AMDeviceNotificationUnsubscribe == NULL ||
|
||||||
@ -1048,163 +1036,6 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
|||||||
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
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) {
|
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
||||||
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
||||||
if (![normalizedPath hasPrefix:@"/"]) {
|
if (![normalizedPath hasPrefix:@"/"]) {
|
||||||
@ -2550,122 +2381,3 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
|||||||
|
|
||||||
return success;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -365,31 +365,6 @@ 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 {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
guard source.origin.kind == .connectedDevice else {
|
guard source.origin.kind == .connectedDevice else {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -25,7 +25,6 @@ protocol SourceAccessMethod: Sendable {
|
|||||||
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
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 listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry]
|
||||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
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
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,12 +108,6 @@ extension SourceAccessMethod {
|
|||||||
return item.folderURL
|
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 {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
_ = source
|
_ = source
|
||||||
}
|
}
|
||||||
@ -211,10 +204,6 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
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 {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,97 +133,6 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
return item.folderURL
|
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(
|
nonisolated private func discoverItemsByReconcilingCache(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
snapshot: SourceSnapshot,
|
snapshot: SourceSnapshot,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import SwiftUI
|
|||||||
struct ItemDetailColumnView: View {
|
struct ItemDetailColumnView: View {
|
||||||
let item: MinecraftContentItem?
|
let item: MinecraftContentItem?
|
||||||
let source: MinecraftSource?
|
let source: MinecraftSource?
|
||||||
let installationState: SourceInstallationState?
|
|
||||||
let showsSourceDetails: Bool
|
let showsSourceDetails: Bool
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
@ -23,7 +22,6 @@ struct ItemDetailColumnView: View {
|
|||||||
let exportAction: () -> Void
|
let exportAction: () -> Void
|
||||||
let revealAction: () -> Void
|
let revealAction: () -> Void
|
||||||
let shareAction: (NSView?) -> Void
|
let shareAction: (NSView?) -> Void
|
||||||
let importAction: (MinecraftSource) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@ -47,13 +45,7 @@ struct ItemDetailColumnView: View {
|
|||||||
shareAction: shareAction
|
shareAction: shareAction
|
||||||
)
|
)
|
||||||
} else if showsSourceDetails, let source {
|
} else if showsSourceDetails, let source {
|
||||||
SourceDetailView(
|
SourceDetailView(source: source)
|
||||||
source: source,
|
|
||||||
installationState: installationState,
|
|
||||||
importAction: {
|
|
||||||
importAction(source)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Select a world or pack to see details")
|
Text("Select a world or pack to see details")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@ -20,38 +20,13 @@ struct SourceDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
let installationState: SourceInstallationState?
|
|
||||||
let importAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
|
||||||
Text(source.displayName)
|
Text(source.displayName)
|
||||||
.font(.largeTitle.weight(.semibold))
|
.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 {
|
if showsStatusSection {
|
||||||
sourceStatusSection
|
sourceStatusSection
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,6 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
let searchPrompt: String
|
let searchPrompt: String
|
||||||
let chooseFolderAction: () -> Void
|
let chooseFolderAction: () -> Void
|
||||||
let dropAction: ([NSItemProvider]) -> Bool
|
let dropAction: ([NSItemProvider]) -> Bool
|
||||||
let dragProvider: (MinecraftContentItem) -> NSItemProvider
|
|
||||||
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -56,14 +55,6 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
List(items, selection: $selectedItemID) { item in
|
List(items, selection: $selectedItemID) { item in
|
||||||
ContentRowView(item: item)
|
ContentRowView(item: item)
|
||||||
.tag(item.id)
|
.tag(item.id)
|
||||||
.onDrag {
|
|
||||||
dragProvider(item)
|
|
||||||
}
|
|
||||||
.simultaneousGesture(
|
|
||||||
TapGesture().onEnded {
|
|
||||||
selectedItemID = item.id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
itemContextMenu(item)
|
itemContextMenu(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -262,9 +262,6 @@ struct SidebarColumnPreviewContainer: View {
|
|||||||
addConnectedDeviceAction: { _ in },
|
addConnectedDeviceAction: { _ in },
|
||||||
rescanSourceAction: { _ in },
|
rescanSourceAction: { _ in },
|
||||||
removeSourceAction: { _ in },
|
removeSourceAction: { _ in },
|
||||||
importSourceAction: { _ in },
|
|
||||||
importDropAction: { _, _ in false },
|
|
||||||
installationState: { _ in nil },
|
|
||||||
filters: { source in
|
filters: { source in
|
||||||
let allFilter = SidebarFilter(
|
let allFilter = SidebarFilter(
|
||||||
title: "All Content",
|
title: "All Content",
|
||||||
@ -334,7 +331,6 @@ struct ItemListColumnPreviewContainer: View {
|
|||||||
searchPrompt: "Search Worlds",
|
searchPrompt: "Search Worlds",
|
||||||
chooseFolderAction: {},
|
chooseFolderAction: {},
|
||||||
dropAction: { _ in false },
|
dropAction: { _ in false },
|
||||||
dragProvider: { _ in NSItemProvider() },
|
|
||||||
itemContextMenu: { item in
|
itemContextMenu: { item in
|
||||||
Button("Reveal \(item.displayName)") {}
|
Button("Reveal \(item.displayName)") {}
|
||||||
}
|
}
|
||||||
@ -349,7 +345,6 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: PreviewFixtures.featuredWorld,
|
item: PreviewFixtures.featuredWorld,
|
||||||
source: PreviewFixtures.primarySource,
|
source: PreviewFixtures.primarySource,
|
||||||
installationState: nil,
|
|
||||||
showsSourceDetails: false,
|
showsSourceDetails: false,
|
||||||
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
||||||
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
||||||
@ -364,8 +359,7 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||||
exportAction: {},
|
exportAction: {},
|
||||||
revealAction: {},
|
revealAction: {},
|
||||||
shareAction: { _ in },
|
shareAction: { _ in }
|
||||||
importAction: { _ in }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ struct ContentView: View {
|
|||||||
@State private var isDropTargeted = false
|
@State private var isDropTargeted = false
|
||||||
@State private var isPerformingItemAction = false
|
@State private var isPerformingItemAction = false
|
||||||
@State private var isShowingDeviceSourceSheet = false
|
@State private var isShowingDeviceSourceSheet = false
|
||||||
@State private var importErrorMessage: String?
|
|
||||||
@State private var sortMode: ItemSortMode = .name
|
@State private var sortMode: ItemSortMode = .name
|
||||||
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
||||||
@State private var showsProjectionLoadingState = false
|
@State private var showsProjectionLoadingState = false
|
||||||
@ -88,11 +87,6 @@ struct ContentView: View {
|
|||||||
removeSourceAction: { source in
|
removeSourceAction: { source in
|
||||||
removeSource(source.id)
|
removeSource(source.id)
|
||||||
},
|
},
|
||||||
importSourceAction: importIntoSource(_:),
|
|
||||||
importDropAction: handleImportDrop(on:providers:),
|
|
||||||
installationState: { source in
|
|
||||||
library.installationStateBySourceID[source.id]
|
|
||||||
},
|
|
||||||
filters: sidebarFilters(for:)
|
filters: sidebarFilters(for:)
|
||||||
)
|
)
|
||||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||||
@ -115,7 +109,6 @@ struct ContentView: View {
|
|||||||
searchPrompt: resolvedItemListProjection.searchPrompt,
|
searchPrompt: resolvedItemListProjection.searchPrompt,
|
||||||
chooseFolderAction: pickFolder,
|
chooseFolderAction: pickFolder,
|
||||||
dropAction: handleDroppedProviders(_:),
|
dropAction: handleDroppedProviders(_:),
|
||||||
dragProvider: dragProvider(for:),
|
|
||||||
itemContextMenu: itemContextMenu(for:)
|
itemContextMenu: itemContextMenu(for:)
|
||||||
)
|
)
|
||||||
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||||
@ -123,7 +116,6 @@ struct ContentView: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: resolvedCurrentSelectedItem,
|
item: resolvedCurrentSelectedItem,
|
||||||
source: resolvedCurrentSource,
|
source: resolvedCurrentSource,
|
||||||
installationState: resolvedCurrentSource.flatMap { library.installationStateBySourceID[$0.id] },
|
|
||||||
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
||||||
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||||
@ -156,8 +148,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shareItem(item, from: anchorView)
|
shareItem(item, from: anchorView)
|
||||||
},
|
}
|
||||||
importAction: importIntoSource(_:)
|
|
||||||
)
|
)
|
||||||
.frame(minWidth: 450)
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
@ -178,23 +169,6 @@ 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 {
|
.task {
|
||||||
AppTerminationCoordinator.shared.register(library: library)
|
AppTerminationCoordinator.shared.register(library: library)
|
||||||
}
|
}
|
||||||
@ -552,166 +526,6 @@ 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 {
|
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||||
let fileURLType = UTType.fileURL.identifier
|
let fileURLType = UTType.fileURL.identifier
|
||||||
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
enum SidebarSelection: Hashable, Sendable {
|
enum SidebarSelection: Hashable, Sendable {
|
||||||
case source(sourceID: URL)
|
case source(sourceID: URL)
|
||||||
@ -34,9 +33,6 @@ struct SourcesSidebarView: View {
|
|||||||
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||||
let rescanSourceAction: (MinecraftSource) -> Void
|
let rescanSourceAction: (MinecraftSource) -> Void
|
||||||
let removeSourceAction: (MinecraftSource) -> Void
|
let removeSourceAction: (MinecraftSource) -> Void
|
||||||
let importSourceAction: (MinecraftSource) -> Void
|
|
||||||
let importDropAction: (MinecraftSource, [NSItemProvider]) -> Bool
|
|
||||||
let installationState: (MinecraftSource) -> SourceInstallationState?
|
|
||||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -85,12 +81,8 @@ struct SourcesSidebarView: View {
|
|||||||
|
|
||||||
SourceHeaderRow(
|
SourceHeaderRow(
|
||||||
source: source,
|
source: source,
|
||||||
installationState: installationState(source),
|
|
||||||
onSelect: {
|
onSelect: {
|
||||||
selection = .source(sourceID: source.id)
|
selection = .source(sourceID: source.id)
|
||||||
},
|
|
||||||
dropAction: { providers in
|
|
||||||
importDropAction(source, providers)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
||||||
@ -101,11 +93,6 @@ struct SourcesSidebarView: View {
|
|||||||
rescanSourceAction(source)
|
rescanSourceAction(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Import into \"\(source.displayName)\"...") {
|
|
||||||
importSourceAction(source)
|
|
||||||
}
|
|
||||||
.disabled(source.availability != .available || !source.capabilities.canInstallItems)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
@ -163,10 +150,7 @@ private struct SidebarSourcesSectionHeaderView: View {
|
|||||||
|
|
||||||
private struct SourceHeaderRow: View {
|
private struct SourceHeaderRow: View {
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
let installationState: SourceInstallationState?
|
|
||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
let dropAction: ([NSItemProvider]) -> Bool
|
|
||||||
@State private var isDropTargeted = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@ -195,23 +179,8 @@ private struct SourceHeaderRow: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.leading, 5)
|
.padding(.leading, 5)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(
|
|
||||||
isDropTargeted ? Color.accentColor.opacity(0.16) : Color.clear,
|
|
||||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(perform: onSelect)
|
.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? {
|
private var connection: DeviceConnection? {
|
||||||
@ -255,23 +224,12 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var showsStatusAccessory: Bool {
|
private var showsStatusAccessory: Bool {
|
||||||
source.isScanning || installationState != nil
|
source.isScanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusAccessory: some View {
|
private var statusAccessory: some View {
|
||||||
if let installationState {
|
if source.isScanning {
|
||||||
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 {
|
if let scanProgress = source.scanProgress {
|
||||||
CircularScanProgressView(progress: scanProgress)
|
CircularScanProgressView(progress: scanProgress)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -771,119 +771,6 @@ 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 {
|
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
@ -1235,24 +1122,6 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
|
#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 {
|
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
|
||||||
let service = ScanNotificationService()
|
let service = ScanNotificationService()
|
||||||
|
|
||||||
@ -1382,82 +1251,6 @@ 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 {
|
private enum ArchiveTestError: LocalizedError {
|
||||||
case failedToCreateArchive(String)
|
case failedToCreateArchive(String)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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,18 +87,6 @@ During materialization:
|
|||||||
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
||||||
- Temporary materialized folders are treated as disposable.
|
- 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
|
## Persistence
|
||||||
|
|
||||||
Connected-device sources are persisted in the SQLite source cache with:
|
Connected-device sources are persisted in the SQLite source cache with:
|
||||||
@ -144,6 +132,3 @@ These commands require a trusted connected device and generally need to run outs
|
|||||||
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
||||||
- Device access requires trust, unlock state, and a vendable app container.
|
- 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.
|
- 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.
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user