Update for async export and refresh
This commit is contained in:
parent
e0886ee50f
commit
e5345b91d8
12
README.md
12
README.md
@ -17,6 +17,8 @@ This thing is called "World Manager," and it happens to pertain to someone else'
|
|||||||
- Detect worlds, resource packs, behavior packs, and world templates.
|
- 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
|
||||||
@ -43,6 +45,14 @@ From an item's detail view, you can export it to a Minecraft package file on you
|
|||||||
|
|
||||||
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
|
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.
|
||||||
@ -97,7 +107,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. The project is currently focused on read-only library access and export, and connected-device discovery, so it shouldn't be doing anything that can break your existing libraries.
|
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. Connected-device installation writes through private Apple MobileDevice interfaces and should be treated as best-effort. The initial implementation installs only into new directories and does not replace or remove existing content.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,7 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -73,6 +73,7 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -7,16 +7,19 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,361 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
nonisolated struct InstallationPayload: Sendable {
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
let preparedDirectoryURL: URL
|
||||||
|
let displayName: String
|
||||||
|
let packUUID: String?
|
||||||
|
let packVersion: String?
|
||||||
|
let suggestedFolderName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct InstallationPlan: Sendable {
|
||||||
|
let payloads: [InstallationPayload]
|
||||||
|
let stagingRootURL: URL
|
||||||
|
|
||||||
|
func cleanup(fileManager: FileManager = .default) {
|
||||||
|
try? fileManager.removeItem(at: stagingRootURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct InstalledContentItem: Sendable {
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
let displayName: String
|
||||||
|
let destinationName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct SourceInstallationState: Hashable, Sendable {
|
||||||
|
let completedCount: Int
|
||||||
|
let totalCount: Int
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MinecraftPackageInstaller {
|
||||||
|
enum InstallError: LocalizedError {
|
||||||
|
case emptyPackage
|
||||||
|
case unsupportedAddonLayout
|
||||||
|
case archiveTooLarge
|
||||||
|
case tooManyEntries
|
||||||
|
case symbolicLinksNotSupported
|
||||||
|
case invalidPreparedContent
|
||||||
|
case duplicatePack(name: String, version: String?)
|
||||||
|
case duplicateArchivePath(String)
|
||||||
|
case partialInstallation(completed: Int, total: Int, reason: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyPackage:
|
||||||
|
return "The Minecraft package does not contain any files."
|
||||||
|
case .unsupportedAddonLayout:
|
||||||
|
return "The .mcaddon package does not contain any supported Minecraft package files."
|
||||||
|
case .archiveTooLarge:
|
||||||
|
return "The expanded Minecraft package is too large to import safely."
|
||||||
|
case .tooManyEntries:
|
||||||
|
return "The Minecraft package contains too many files to import safely."
|
||||||
|
case .symbolicLinksNotSupported:
|
||||||
|
return "Minecraft packages containing symbolic links are not supported."
|
||||||
|
case .invalidPreparedContent:
|
||||||
|
return "The prepared content does not contain a valid Minecraft world, pack, or template."
|
||||||
|
case .duplicatePack(let name, let version):
|
||||||
|
if let version, !version.isEmpty {
|
||||||
|
return "\(name) version \(version) is already installed in this source."
|
||||||
|
}
|
||||||
|
return "\(name) is already installed in this source."
|
||||||
|
case .duplicateArchivePath(let path):
|
||||||
|
return "The Minecraft package contains more than one entry for \(path)."
|
||||||
|
case .partialInstallation(let completed, let total, let reason):
|
||||||
|
return "Installed \(completed) of \(total) items before the import stopped: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let maximumEntryCount = 100_000
|
||||||
|
private static let maximumExpandedSize: UInt64 = 16 * 1_024 * 1_024 * 1_024
|
||||||
|
|
||||||
|
nonisolated static func preparePackage(at packageURL: URL) throws -> InstallationPlan {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let stagingRootURL = fileManager.temporaryDirectory
|
||||||
|
.appendingPathComponent("MinecraftPackageInstallation", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let payloads = try preparePackage(
|
||||||
|
at: packageURL.standardizedFileURL,
|
||||||
|
inside: stagingRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
guard !payloads.isEmpty else {
|
||||||
|
throw InstallError.emptyPackage
|
||||||
|
}
|
||||||
|
return InstallationPlan(payloads: payloads, stagingRootURL: stagingRootURL)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: stagingRootURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func prepareDirectory(
|
||||||
|
at directoryURL: URL,
|
||||||
|
contentType: MinecraftContentType
|
||||||
|
) throws -> InstallationPlan {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let stagingRootURL = fileManager.temporaryDirectory
|
||||||
|
.appendingPathComponent("MinecraftDirectoryInstallation", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let payloadDirectoryURL = stagingRootURL.appendingPathComponent("payload", isDirectory: true)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try validateDirectoryTree(at: directoryURL, fileManager: fileManager)
|
||||||
|
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
||||||
|
try fileManager.copyItem(at: directoryURL, to: payloadDirectoryURL)
|
||||||
|
let payload = try makePayload(
|
||||||
|
contentRootURL: payloadDirectoryURL,
|
||||||
|
contentType: contentType,
|
||||||
|
fallbackName: directoryURL.lastPathComponent,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
return InstallationPlan(payloads: [payload], stagingRootURL: stagingRootURL)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: stagingRootURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func preparePackage(
|
||||||
|
at packageURL: URL,
|
||||||
|
inside stagingRootURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) throws -> [InstallationPayload] {
|
||||||
|
let pathExtension = packageURL.pathExtension.lowercased()
|
||||||
|
guard MinecraftPackageInspector.supportedPathExtensions.contains(pathExtension) else {
|
||||||
|
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive = try ZipArchiveReader(url: packageURL)
|
||||||
|
try validate(entries: archive.entries)
|
||||||
|
|
||||||
|
if pathExtension == "mcaddon" {
|
||||||
|
return try prepareAddon(
|
||||||
|
archive: archive,
|
||||||
|
packageURL: packageURL,
|
||||||
|
stagingRootURL: stagingRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentRootPath = try MinecraftPackageInspector.resolvedContentRootPath(
|
||||||
|
in: archive.entries,
|
||||||
|
archivePathExtension: pathExtension
|
||||||
|
)
|
||||||
|
let payloadDirectoryURL = stagingRootURL
|
||||||
|
.appendingPathComponent("payload-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try extract(
|
||||||
|
archive: archive,
|
||||||
|
contentRootPath: contentRootPath,
|
||||||
|
to: payloadDirectoryURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
switch pathExtension {
|
||||||
|
case "mcworld":
|
||||||
|
contentType = .world
|
||||||
|
case "mctemplate":
|
||||||
|
contentType = .worldTemplate
|
||||||
|
case "mcpack":
|
||||||
|
contentType = MinecraftContentMetadataReader.inferredPackContentType(
|
||||||
|
for: payloadDirectoryURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
try makePayload(
|
||||||
|
contentRootURL: payloadDirectoryURL,
|
||||||
|
contentType: contentType,
|
||||||
|
fallbackName: packageURL.deletingPathExtension().lastPathComponent,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func prepareAddon(
|
||||||
|
archive: ZipArchiveReader,
|
||||||
|
packageURL: URL,
|
||||||
|
stagingRootURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) throws -> [InstallationPayload] {
|
||||||
|
let nestedEntries = archive.entries.filter { entry in
|
||||||
|
guard !entry.isDirectory else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ["mcpack", "mcworld", "mctemplate"].contains(
|
||||||
|
URL(fileURLWithPath: entry.path).pathExtension.lowercased()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !nestedEntries.isEmpty else {
|
||||||
|
throw InstallError.unsupportedAddonLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloads: [InstallationPayload] = []
|
||||||
|
for nestedEntry in nestedEntries {
|
||||||
|
let nestedPackageURL = stagingRootURL
|
||||||
|
.appendingPathComponent("nested-\(UUID().uuidString)")
|
||||||
|
.appendingPathExtension(URL(fileURLWithPath: nestedEntry.path).pathExtension)
|
||||||
|
try archive.extract(nestedEntry).write(to: nestedPackageURL, options: .atomic)
|
||||||
|
payloads.append(
|
||||||
|
contentsOf: try preparePackage(
|
||||||
|
at: nestedPackageURL,
|
||||||
|
inside: stagingRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try? fileManager.removeItem(at: nestedPackageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = packageURL
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func validate(entries: [ZipArchiveEntry]) throws {
|
||||||
|
guard !entries.isEmpty else {
|
||||||
|
throw InstallError.emptyPackage
|
||||||
|
}
|
||||||
|
guard entries.count <= maximumEntryCount else {
|
||||||
|
throw InstallError.tooManyEntries
|
||||||
|
}
|
||||||
|
guard !entries.contains(where: \.isSymbolicLink) else {
|
||||||
|
throw InstallError.symbolicLinksNotSupported
|
||||||
|
}
|
||||||
|
var paths = Set<String>()
|
||||||
|
for entry in entries where !entry.isDirectory {
|
||||||
|
guard paths.insert(entry.path).inserted else {
|
||||||
|
throw InstallError.duplicateArchivePath(entry.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expandedSize = entries.reduce(UInt64(0)) { partial, entry in
|
||||||
|
partial + UInt64(entry.uncompressedSize)
|
||||||
|
}
|
||||||
|
guard expandedSize <= maximumExpandedSize else {
|
||||||
|
throw InstallError.archiveTooLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func extract(
|
||||||
|
archive: ZipArchiveReader,
|
||||||
|
contentRootPath: String,
|
||||||
|
to destinationRootURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) throws {
|
||||||
|
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
|
||||||
|
let prefix = contentRootPath.isEmpty ? "" : contentRootPath + "/"
|
||||||
|
|
||||||
|
for entry in archive.entries {
|
||||||
|
guard entry.path.hasPrefix(prefix) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let relativePath = String(entry.path.dropFirst(prefix.count))
|
||||||
|
guard !relativePath.isEmpty else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationURL = destinationRootURL.appendingPathComponent(relativePath)
|
||||||
|
if entry.isDirectory {
|
||||||
|
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: destinationURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try archive.extract(entry).write(to: destinationURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func makePayload(
|
||||||
|
contentRootURL: URL,
|
||||||
|
contentType: MinecraftContentType,
|
||||||
|
fallbackName: String,
|
||||||
|
fileManager: FileManager
|
||||||
|
) throws -> InstallationPayload {
|
||||||
|
guard isValidContent(at: contentRootURL, type: contentType, fileManager: fileManager) else {
|
||||||
|
throw InstallError.invalidPreparedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = MinecraftContentMetadataReader.manifestMetadata(
|
||||||
|
in: contentRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
let displayName = MinecraftContentMetadataReader.displayName(
|
||||||
|
for: contentRootURL,
|
||||||
|
contentType: contentType,
|
||||||
|
fallbackName: fallbackName,
|
||||||
|
fileManager: fileManager
|
||||||
|
)
|
||||||
|
let preferredFolderName = manifest?.uuid ?? fallbackName
|
||||||
|
|
||||||
|
return InstallationPayload(
|
||||||
|
contentType: contentType,
|
||||||
|
preparedDirectoryURL: contentRootURL,
|
||||||
|
displayName: displayName,
|
||||||
|
packUUID: manifest?.uuid,
|
||||||
|
packVersion: manifest?.version,
|
||||||
|
suggestedFolderName: sanitizedFolderName(preferredFolderName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func isValidContent(
|
||||||
|
at directoryURL: URL,
|
||||||
|
type: MinecraftContentType,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> Bool {
|
||||||
|
switch type {
|
||||||
|
case .world:
|
||||||
|
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path)
|
||||||
|
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path)
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func sanitizedFolderName(_ value: String) -> String {
|
||||||
|
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
||||||
|
let components = value.components(separatedBy: invalidCharacters)
|
||||||
|
let sanitized = components.joined(separator: "-")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return sanitized.isEmpty ? UUID().uuidString : String(sanitized.prefix(80))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func validateDirectoryTree(
|
||||||
|
at directoryURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) throws {
|
||||||
|
guard let enumerator = fileManager.enumerator(
|
||||||
|
at: directoryURL,
|
||||||
|
includingPropertiesForKeys: [.isSymbolicLinkKey],
|
||||||
|
options: []
|
||||||
|
) else {
|
||||||
|
throw InstallError.invalidPreparedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryCount = 0
|
||||||
|
for case let entryURL as URL in enumerator {
|
||||||
|
entryCount += 1
|
||||||
|
guard entryCount <= maximumEntryCount else {
|
||||||
|
throw InstallError.tooManyEntries
|
||||||
|
}
|
||||||
|
if (try entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true {
|
||||||
|
throw InstallError.symbolicLinksNotSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -147,7 +147,7 @@ enum MinecraftPackageInspector {
|
|||||||
return contentRootURL
|
return contentRootURL
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func resolvedContentRootPath(
|
nonisolated 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 private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
nonisolated static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
||||||
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
||||||
|
|
||||||
let worldMarkers = ["level.dat", "levelname.txt"]
|
let worldMarkers = ["level.dat", "levelname.txt"]
|
||||||
|
|||||||
@ -11,12 +11,14 @@ 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)
|
||||||
|
|
||||||
@ -28,6 +30,8 @@ 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):
|
||||||
@ -41,7 +45,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let entries: [ZipArchiveEntry]
|
let entries: [ZipArchiveEntry]
|
||||||
|
|
||||||
init(url: URL) throws {
|
init(url: URL) throws {
|
||||||
self.data = try Data(contentsOf: url)
|
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||||
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,6 +82,9 @@ 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))
|
||||||
@ -115,6 +122,7 @@ 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
|
||||||
@ -127,7 +135,9 @@ 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,
|
||||||
@ -135,7 +145,8 @@ 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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -178,6 +189,18 @@ 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()
|
||||||
@ -228,11 +251,17 @@ 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,6 +29,7 @@ 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>] = [:]
|
||||||
@ -250,6 +251,143 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installPackages(at packageURLs: [URL], into sourceID: URL) async throws -> [InstalledContentItem] {
|
||||||
|
guard
|
||||||
|
let destinationSource = source(withID: sourceID),
|
||||||
|
destinationSource.availability == .available,
|
||||||
|
destinationSource.capabilities.canInstallItems
|
||||||
|
else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "This source is not currently available for installation.")
|
||||||
|
}
|
||||||
|
guard !packageURLs.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
scanTasks[sourceID]?.cancel()
|
||||||
|
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||||
|
completedCount: 0,
|
||||||
|
totalCount: packageURLs.count,
|
||||||
|
status: "Preparing import..."
|
||||||
|
)
|
||||||
|
|
||||||
|
var plans: [InstallationPlan] = []
|
||||||
|
var installedItems: [InstalledContentItem] = []
|
||||||
|
var totalPayloadCount = 0
|
||||||
|
do {
|
||||||
|
for packageURL in packageURLs {
|
||||||
|
let plan = try await Task.detached(priority: .userInitiated) {
|
||||||
|
let accessedSecurityScope = packageURL.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessedSecurityScope {
|
||||||
|
packageURL.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try MinecraftPackageInstaller.preparePackage(at: packageURL)
|
||||||
|
}.value
|
||||||
|
plans.append(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloads = plans.flatMap(\.payloads)
|
||||||
|
totalPayloadCount = payloads.count
|
||||||
|
try validatePackConflicts(payloads, destinationSource: destinationSource)
|
||||||
|
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||||
|
completedCount: 0,
|
||||||
|
totalCount: payloads.count,
|
||||||
|
status: payloads.count == 1 ? "Installing item..." : "Installing \(payloads.count) items..."
|
||||||
|
)
|
||||||
|
|
||||||
|
for payload in payloads {
|
||||||
|
guard let currentDestination = source(withID: sourceID) else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "The destination source was removed during installation.")
|
||||||
|
}
|
||||||
|
let installedItem = try await sourceAccessMethod.install(payload, in: currentDestination)
|
||||||
|
installedItems.append(installedItem)
|
||||||
|
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||||
|
completedCount: installedItems.count,
|
||||||
|
totalCount: payloads.count,
|
||||||
|
status: "Installed \(installedItems.count) of \(payloads.count)..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
plans.forEach { $0.cleanup() }
|
||||||
|
installationStateBySourceID[sourceID] = nil
|
||||||
|
startScan(for: sourceID, mode: .fullScan)
|
||||||
|
return installedItems
|
||||||
|
} catch {
|
||||||
|
plans.forEach { $0.cleanup() }
|
||||||
|
installationStateBySourceID[sourceID] = nil
|
||||||
|
if !installedItems.isEmpty {
|
||||||
|
startScan(for: sourceID, mode: .fullScan)
|
||||||
|
throw MinecraftPackageInstaller.InstallError.partialInstallation(
|
||||||
|
completed: installedItems.count,
|
||||||
|
total: totalPayloadCount,
|
||||||
|
reason: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyItem(
|
||||||
|
_ item: MinecraftContentItem,
|
||||||
|
from sourceID: URL,
|
||||||
|
into destinationSourceID: URL
|
||||||
|
) async throws -> [InstalledContentItem] {
|
||||||
|
guard let source = source(withID: sourceID) else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "The source item is no longer available.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let representation = try await externalRepresentation(
|
||||||
|
for: item,
|
||||||
|
in: source,
|
||||||
|
preferredKind: .portablePackage
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
if representation.isTemporary {
|
||||||
|
try? FileManager.default.removeItem(at: representation.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await installPackages(at: [representation.url], into: destinationSourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validatePackConflicts(
|
||||||
|
_ payloads: [InstallationPayload],
|
||||||
|
destinationSource: MinecraftSource
|
||||||
|
) throws {
|
||||||
|
var existingIdentities = Set(
|
||||||
|
destinationSource.rawItems.compactMap { item -> String? in
|
||||||
|
guard
|
||||||
|
item.contentType == .behaviorPack ||
|
||||||
|
item.contentType == .resourcePack ||
|
||||||
|
item.contentType == .skinPack,
|
||||||
|
let uuid = item.packUUID?.lowercased()
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "\(item.contentType.rawValue)::\(uuid)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for payload in payloads {
|
||||||
|
guard
|
||||||
|
payload.contentType == .behaviorPack ||
|
||||||
|
payload.contentType == .resourcePack ||
|
||||||
|
payload.contentType == .skinPack,
|
||||||
|
let uuid = payload.packUUID?.lowercased()
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let identity = "\(payload.contentType.rawValue)::\(uuid)"
|
||||||
|
guard !existingIdentities.contains(identity) else {
|
||||||
|
throw MinecraftPackageInstaller.InstallError.duplicatePack(
|
||||||
|
name: payload.displayName,
|
||||||
|
version: payload.packVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
existingIdentities.insert(identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func removeSource(withID sourceID: URL) {
|
func removeSource(withID sourceID: URL) {
|
||||||
let removedSource = source(withID: sourceID)
|
let removedSource = source(withID: sourceID)
|
||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
|
|||||||
@ -120,7 +120,8 @@ 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:
|
||||||
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
|
let remainingCount = max(source.indexedItemCount - source.sizeLoadedCount, 0)
|
||||||
|
return "Calculating sizes for \(remainingCount) of \(source.indexedItemCount) items..."
|
||||||
case .completed:
|
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:
|
||||||
|
|||||||
@ -750,7 +750,8 @@ private actor SourceIndexActor {
|
|||||||
} else if sizeLoadedCount == 0 {
|
} else if sizeLoadedCount == 0 {
|
||||||
scanStatus = "Preparing size calculations..."
|
scanStatus = "Preparing size calculations..."
|
||||||
} else {
|
} else {
|
||||||
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
|
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
|
||||||
|
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scanStatus = indexedItemCount == 0
|
scanStatus = indexedItemCount == 0
|
||||||
|
|||||||
@ -177,6 +177,37 @@ enum AppleMobileDeviceAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func installDirectory(
|
||||||
|
deviceIdentifier: String,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
minecraftRootRelativePath: String,
|
||||||
|
collectionFolderName: String,
|
||||||
|
preferredDestinationName: String,
|
||||||
|
sourceDirectoryURL: URL
|
||||||
|
) async throws -> String {
|
||||||
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
var error: NSError?
|
||||||
|
guard let installedName = WMMInstallLocalDirectoryInConnectedDeviceApp(
|
||||||
|
deviceIdentifier,
|
||||||
|
bundleIdentifier,
|
||||||
|
minecraftRootRelativePath,
|
||||||
|
collectionFolderName,
|
||||||
|
preferredDestinationName,
|
||||||
|
sourceDirectoryURL,
|
||||||
|
&error
|
||||||
|
) else {
|
||||||
|
throw error ?? NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 16,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice content installation failed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return installedName
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
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,4 +98,15 @@ 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,7 +122,11 @@ 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;
|
||||||
@ -159,7 +163,11 @@ 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 {
|
||||||
@ -237,7 +245,11 @@ 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 ||
|
||||||
@ -1036,6 +1048,163 @@ 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:@"/"]) {
|
||||||
@ -2381,3 +2550,122 @@ 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,6 +365,31 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
||||||
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "The selected source is not a connected device.")
|
||||||
|
}
|
||||||
|
let minecraftRoot = container.minecraftFolderRelativePath?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !minecraftRoot.isEmpty else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "The connected device is missing its Minecraft content path.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationName = try await AppleMobileDeviceAccess.installDirectory(
|
||||||
|
deviceIdentifier: container.deviceUDID,
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
minecraftRootRelativePath: minecraftRoot,
|
||||||
|
collectionFolderName: payload.contentType.collectionFolderName,
|
||||||
|
preferredDestinationName: payload.suggestedFolderName,
|
||||||
|
sourceDirectoryURL: payload.preparedDirectoryURL
|
||||||
|
)
|
||||||
|
return InstalledContentItem(
|
||||||
|
contentType: payload.contentType,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
destinationName: destinationName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
guard source.origin.kind == .connectedDevice else {
|
guard source.origin.kind == .connectedDevice else {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -25,6 +25,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +109,12 @@ 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
|
||||||
}
|
}
|
||||||
@ -204,6 +211,10 @@ 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,6 +133,97 @@ 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,6 +7,7 @@ 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]
|
||||||
@ -22,6 +23,7 @@ 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 {
|
||||||
@ -45,7 +47,13 @@ struct ItemDetailColumnView: View {
|
|||||||
shareAction: shareAction
|
shareAction: shareAction
|
||||||
)
|
)
|
||||||
} else if showsSourceDetails, let source {
|
} else if showsSourceDetails, let source {
|
||||||
SourceDetailView(source: source)
|
SourceDetailView(
|
||||||
|
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,13 +20,38 @@ 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,6 +41,7 @@ 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 {
|
||||||
@ -55,6 +56,14 @@ 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,6 +262,9 @@ 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",
|
||||||
@ -331,6 +334,7 @@ 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)") {}
|
||||||
}
|
}
|
||||||
@ -345,6 +349,7 @@ 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),
|
||||||
@ -359,7 +364,8 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||||
exportAction: {},
|
exportAction: {},
|
||||||
revealAction: {},
|
revealAction: {},
|
||||||
shareAction: { _ in }
|
shareAction: { _ in },
|
||||||
|
importAction: { _ in }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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
|
||||||
@ -87,6 +88,11 @@ 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)
|
||||||
@ -109,6 +115,7 @@ 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)
|
||||||
@ -116,6 +123,7 @@ 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) } ?? [],
|
||||||
@ -148,7 +156,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shareItem(item, from: anchorView)
|
shareItem(item, from: anchorView)
|
||||||
}
|
},
|
||||||
|
importAction: importIntoSource(_:)
|
||||||
)
|
)
|
||||||
.frame(minWidth: 450)
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
@ -169,6 +178,23 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
"Import Failed",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { importErrorMessage != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
importErrorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button("OK") {
|
||||||
|
importErrorMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(importErrorMessage ?? "The Minecraft content could not be imported.")
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
AppTerminationCoordinator.shared.register(library: library)
|
AppTerminationCoordinator.shared.register(library: library)
|
||||||
}
|
}
|
||||||
@ -526,6 +552,166 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func importIntoSource(_ source: MinecraftSource) {
|
||||||
|
guard source.availability == .available, source.capabilities.canInstallItems else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.title = "Import Minecraft Content into \(source.displayName)"
|
||||||
|
panel.prompt = "Import"
|
||||||
|
panel.allowedContentTypes = [
|
||||||
|
.minecraftWorld,
|
||||||
|
.minecraftPack,
|
||||||
|
.minecraftTemplate,
|
||||||
|
.minecraftAddon
|
||||||
|
]
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await performImport(packageURLs: panel.urls, into: source.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleImportDrop(
|
||||||
|
on source: MinecraftSource,
|
||||||
|
providers: [NSItemProvider]
|
||||||
|
) -> Bool {
|
||||||
|
guard
|
||||||
|
source.availability == .available,
|
||||||
|
source.capabilities.canInstallItems,
|
||||||
|
providers.contains(where: isSupportedImportProvider(_:))
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let materializedDrop = await materializeDroppedPackages(from: providers)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: materializedDrop.temporaryRootURL)
|
||||||
|
}
|
||||||
|
guard !materializedDrop.packageURLs.isEmpty else {
|
||||||
|
await MainActor.run {
|
||||||
|
importErrorMessage = "The dropped items did not contain a supported .mcworld, .mcpack, .mctemplate, or .mcaddon file."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await performImport(packageURLs: materializedDrop.packageURLs, into: source.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func performImport(packageURLs: [URL], into sourceID: URL) async {
|
||||||
|
do {
|
||||||
|
_ = try await library.installPackages(at: packageURLs, into: sourceID)
|
||||||
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
|
selectedItemID = nil
|
||||||
|
} catch {
|
||||||
|
importErrorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSupportedImportProvider(_ provider: NSItemProvider) -> Bool {
|
||||||
|
importTypeIdentifiers.contains {
|
||||||
|
provider.hasItemConformingToTypeIdentifier($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var importTypeIdentifiers: [String] {
|
||||||
|
[
|
||||||
|
UTType.minecraftWorld.identifier,
|
||||||
|
UTType.minecraftPack.identifier,
|
||||||
|
UTType.minecraftTemplate.identifier,
|
||||||
|
UTType.minecraftAddon.identifier,
|
||||||
|
UTType.fileURL.identifier
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materializeDroppedPackages(
|
||||||
|
from providers: [NSItemProvider]
|
||||||
|
) async -> (packageURLs: [URL], temporaryRootURL: URL) {
|
||||||
|
let temporaryRootURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("MinecraftImportDrop", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
var packageURLs: [URL] = []
|
||||||
|
for provider in providers where isSupportedImportProvider(provider) {
|
||||||
|
if let packageURL = await materializeDroppedPackage(
|
||||||
|
from: provider,
|
||||||
|
in: temporaryRootURL
|
||||||
|
) {
|
||||||
|
packageURLs.append(packageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (packageURLs, temporaryRootURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materializeDroppedPackage(
|
||||||
|
from provider: NSItemProvider,
|
||||||
|
in temporaryRootURL: URL
|
||||||
|
) async -> URL? {
|
||||||
|
let packageDefinitions = MinecraftPackageTypes.all
|
||||||
|
if let definition = packageDefinitions.first(where: {
|
||||||
|
provider.hasItemConformingToTypeIdentifier($0.utTypeIdentifier)
|
||||||
|
}) {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: definition.utTypeIdentifier) { url, _ in
|
||||||
|
guard let url else {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let baseName = provider.suggestedName.map {
|
||||||
|
URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent
|
||||||
|
} ?? UUID().uuidString
|
||||||
|
let destinationURL = temporaryRootURL
|
||||||
|
.appendingPathComponent(baseName)
|
||||||
|
.appendingPathExtension(definition.pathExtension)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: url, to: destinationURL)
|
||||||
|
continuation.resume(returning: destinationURL)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
|
||||||
|
guard
|
||||||
|
let data,
|
||||||
|
let sourceURL = NSURL(
|
||||||
|
absoluteURLWithDataRepresentation: data,
|
||||||
|
relativeTo: nil
|
||||||
|
) as URL?,
|
||||||
|
MinecraftPackageInspector.supportedPathExtensions.contains(
|
||||||
|
sourceURL.pathExtension.lowercased()
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let destinationURL = temporaryRootURL.appendingPathComponent(sourceURL.lastPathComponent)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
continuation.resume(returning: destinationURL)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
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,6 +2,7 @@
|
|||||||
// 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)
|
||||||
@ -33,6 +34,9 @@ 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 {
|
||||||
@ -81,8 +85,12 @@ 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?)
|
||||||
@ -93,6 +101,11 @@ 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) {
|
||||||
@ -150,7 +163,10 @@ 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) {
|
||||||
@ -179,8 +195,23 @@ 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? {
|
||||||
@ -224,12 +255,23 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var showsStatusAccessory: Bool {
|
private var showsStatusAccessory: Bool {
|
||||||
source.isScanning
|
source.isScanning || installationState != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusAccessory: some View {
|
private var statusAccessory: some View {
|
||||||
if source.isScanning {
|
if let installationState {
|
||||||
|
if installationState.totalCount > 0 {
|
||||||
|
CircularScanProgressView(
|
||||||
|
progress: Double(installationState.completedCount) / Double(installationState.totalCount)
|
||||||
|
)
|
||||||
|
.help(installationState.status)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.appActivityIndicatorStyle(.small)
|
||||||
|
.help(installationState.status)
|
||||||
|
}
|
||||||
|
} else if source.isScanning {
|
||||||
if let scanProgress = source.scanProgress {
|
if let scanProgress = source.scanProgress {
|
||||||
CircularScanProgressView(progress: scanProgress)
|
CircularScanProgressView(progress: scanProgress)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -771,6 +771,119 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func packageInstallerPreparesCompleteWorldContents() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let worldURL = workingURL.appendingPathComponent("World", isDirectory: true)
|
||||||
|
let archiveURL = workingURL.appendingPathComponent("World.mcworld", isDirectory: false)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: worldURL.appendingPathComponent("db", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data([1, 2, 3]).write(to: worldURL.appendingPathComponent("level.dat"))
|
||||||
|
try Data([4, 5, 6]).write(to: worldURL.appendingPathComponent("db/chunk.bin"))
|
||||||
|
try makeArchive(from: worldURL, to: archiveURL)
|
||||||
|
|
||||||
|
let plan = try MinecraftPackageInstaller.preparePackage(at: archiveURL)
|
||||||
|
defer { plan.cleanup() }
|
||||||
|
|
||||||
|
#expect(plan.payloads.count == 1)
|
||||||
|
#expect(plan.payloads[0].contentType == .world)
|
||||||
|
#expect(
|
||||||
|
fileManager.fileExists(
|
||||||
|
atPath: plan.payloads[0].preparedDirectoryURL.appendingPathComponent("db/chunk.bin").path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func packageInstallerExpandsNestedAddonPackages() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let behaviorURL = workingURL.appendingPathComponent("Behavior", isDirectory: true)
|
||||||
|
let resourceURL = workingURL.appendingPathComponent("Resource", isDirectory: true)
|
||||||
|
let addonRootURL = workingURL.appendingPathComponent("Addon", isDirectory: true)
|
||||||
|
let behaviorArchiveURL = addonRootURL.appendingPathComponent("Behavior.mcpack")
|
||||||
|
let resourceArchiveURL = addonRootURL.appendingPathComponent("Resource.mcpack")
|
||||||
|
let addonURL = workingURL.appendingPathComponent("Combined.mcaddon")
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: behaviorURL, withIntermediateDirectories: true)
|
||||||
|
try fileManager.createDirectory(at: resourceURL, withIntermediateDirectories: true)
|
||||||
|
try fileManager.createDirectory(at: addonRootURL, withIntermediateDirectories: true)
|
||||||
|
try testPackManifest(name: "Behavior", uuid: "11111111-1111-1111-1111-111111111111", moduleType: "data")
|
||||||
|
.write(to: behaviorURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
||||||
|
try testPackManifest(name: "Resource", uuid: "22222222-2222-2222-2222-222222222222", moduleType: "resources")
|
||||||
|
.write(to: resourceURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
||||||
|
try makeArchive(from: behaviorURL, to: behaviorArchiveURL)
|
||||||
|
try makeArchive(from: resourceURL, to: resourceArchiveURL)
|
||||||
|
try makeArchive(from: addonRootURL, to: addonURL)
|
||||||
|
|
||||||
|
let plan = try MinecraftPackageInstaller.preparePackage(at: addonURL)
|
||||||
|
defer { plan.cleanup() }
|
||||||
|
|
||||||
|
#expect(Set(plan.payloads.map(\.contentType)) == [.behaviorPack, .resourcePack])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func localFolderInstallerUsesUniqueAdditiveDestinations() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let sourceWorldURL = workingURL.appendingPathComponent("PreparedWorld", isDirectory: true)
|
||||||
|
let destinationRootURL = workingURL.appendingPathComponent("Destination", isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: sourceWorldURL, withIntermediateDirectories: true)
|
||||||
|
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
|
||||||
|
try Data([1]).write(to: sourceWorldURL.appendingPathComponent("level.dat"))
|
||||||
|
|
||||||
|
let plan = try MinecraftPackageInstaller.prepareDirectory(at: sourceWorldURL, contentType: .world)
|
||||||
|
defer { plan.cleanup() }
|
||||||
|
let source = MinecraftSource(folderURL: destinationRootURL, availability: .available)
|
||||||
|
let access = LocalFolderSourceAccess()
|
||||||
|
|
||||||
|
let first = try await access.install(plan.payloads[0], in: source)
|
||||||
|
let second = try await access.install(plan.payloads[0], in: source)
|
||||||
|
|
||||||
|
#expect(first.destinationName != second.destinationName)
|
||||||
|
#expect(
|
||||||
|
fileManager.fileExists(
|
||||||
|
atPath: destinationRootURL
|
||||||
|
.appendingPathComponent("minecraftWorlds/\(first.destinationName)/level.dat")
|
||||||
|
.path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
fileManager.fileExists(
|
||||||
|
atPath: destinationRootURL
|
||||||
|
.appendingPathComponent("minecraftWorlds/\(second.destinationName)/level.dat")
|
||||||
|
.path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func zipReaderRejectsParentTraversalEntry() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let archiveURL = workingURL.appendingPathComponent("Unsafe.mcworld")
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
|
||||||
|
try makeStoredArchive(entries: [("../escape.txt", Data("escape".utf8))], to: archiveURL)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try ZipArchiveReader(url: archiveURL)
|
||||||
|
Issue.record("Expected the unsafe archive path to be rejected.")
|
||||||
|
} catch let error as ZipArchiveReaderError {
|
||||||
|
switch error {
|
||||||
|
case .unsafeEntryPath(let path):
|
||||||
|
#expect(path == "../escape.txt")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected unsafeEntryPath but received \(error).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
@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)
|
||||||
@ -1122,6 +1235,24 @@ 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()
|
||||||
|
|
||||||
@ -1251,6 +1382,82 @@ private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throw
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func testPackManifest(name: String, uuid: String, moduleType: String) -> String {
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"header": {
|
||||||
|
"name": "\(name)",
|
||||||
|
"description": "\(name)",
|
||||||
|
"uuid": "\(uuid)",
|
||||||
|
"version": [1, 0, 0]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"type": "\(moduleType)",
|
||||||
|
"uuid": "\(UUID().uuidString)",
|
||||||
|
"version": [1, 0, 0]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStoredArchive(entries: [(String, Data)], to archiveURL: URL) throws {
|
||||||
|
var archive = Data()
|
||||||
|
var centralDirectory = Data()
|
||||||
|
|
||||||
|
for (path, contents) in entries {
|
||||||
|
let pathData = Data(path.utf8)
|
||||||
|
let localHeaderOffset = UInt32(archive.count)
|
||||||
|
|
||||||
|
appendLE(UInt32(0x04034b50), to: &archive)
|
||||||
|
appendLE(UInt16(20), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt32(0), to: &archive)
|
||||||
|
appendLE(UInt32(contents.count), to: &archive)
|
||||||
|
appendLE(UInt32(contents.count), to: &archive)
|
||||||
|
appendLE(UInt16(pathData.count), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
archive.append(pathData)
|
||||||
|
archive.append(contents)
|
||||||
|
|
||||||
|
appendLE(UInt32(0x02014b50), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(20), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(20), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt32(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(pathData.count), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||||
|
appendLE(UInt32(0), to: ¢ralDirectory)
|
||||||
|
appendLE(localHeaderOffset, to: ¢ralDirectory)
|
||||||
|
centralDirectory.append(pathData)
|
||||||
|
}
|
||||||
|
|
||||||
|
let centralDirectoryOffset = UInt32(archive.count)
|
||||||
|
archive.append(centralDirectory)
|
||||||
|
appendLE(UInt32(0x06054b50), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
appendLE(UInt16(entries.count), to: &archive)
|
||||||
|
appendLE(UInt16(entries.count), to: &archive)
|
||||||
|
appendLE(UInt32(centralDirectory.count), to: &archive)
|
||||||
|
appendLE(centralDirectoryOffset, to: &archive)
|
||||||
|
appendLE(UInt16(0), to: &archive)
|
||||||
|
try archive.write(to: archiveURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
private enum ArchiveTestError: LocalizedError {
|
private enum ArchiveTestError: LocalizedError {
|
||||||
case failedToCreateArchive(String)
|
case failedToCreateArchive(String)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
World Manager can browse Minecraft Bedrock content from a trusted iPhone or iPad on macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
World Manager can browse and install Minecraft Bedrock content on a trusted iPhone or iPad from macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
||||||
|
|
||||||
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
|
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
|
||||||
|
|
||||||
@ -87,6 +87,18 @@ During materialization:
|
|||||||
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
- `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:
|
||||||
@ -132,3 +144,6 @@ These commands require a trusted connected device and generally need to run outs
|
|||||||
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
- 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