Compare commits
2 Commits
3a97ae0d53
...
e5345b91d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5345b91d8 | ||
|
|
e0886ee50f |
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.
|
||||
- Inspect package metadata, pack relationships, icons, versions, UUIDs, and basic world facts.
|
||||
- Export worlds and packs as portable `.mcworld`, `.mcpack`, `.mctemplate`, and `.mcaddon` packages, or share them via Messages, AirDrop, etc. via the "Share" sheet.
|
||||
- Import `.mcworld`, `.mcpack`, `.mctemplate`, and nested-package `.mcaddon` files into writable folder and connected-device sources.
|
||||
- Copy an item between sources by dragging it onto the destination source in the sidebar.
|
||||
- Preview and thumbnail supported Minecraft package files with Quick Look extensions.
|
||||
|
||||
## Usage
|
||||
@ -43,6 +45,14 @@ From an item's detail view, you can export it to a Minecraft package file on you
|
||||
|
||||
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
|
||||
|
||||
### Importing and Copying
|
||||
|
||||
Select a source and use `Import...` on its overview, or drop a supported Minecraft package onto the source row in the sidebar. World Manager inspects the package and places each item in the collection appropriate to its content type.
|
||||
|
||||
You can also drag an existing world or pack from the item list onto another source. The app exports a temporary portable representation, validates it through the same import path, and installs it in the destination.
|
||||
|
||||
Imports are additive. Existing worlds are never overwritten. A second world import receives a unique directory name. Packs with a UUID already present in the destination are rejected instead of being silently replaced.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 26.2 or newer, based on the current Xcode project deployment target. Could work with older macOS. I don't have any so I don't know.
|
||||
@ -97,7 +107,7 @@ See [docs/ios-device-access.md](docs/ios-device-access.md) for the current devic
|
||||
|
||||
## Project Status
|
||||
|
||||
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. The project is currently focused on read-only library access and export, and connected-device discovery, so it shouldn't be doing anything that can break your existing libraries.
|
||||
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. Connected-device installation writes through private Apple MobileDevice interfaces and should be treated as best-effort. The initial implementation installs only into new directories and does not replace or remove existing content.
|
||||
|
||||
## Trademarks
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
||||
@ -73,6 +73,7 @@
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
||||
@ -7,16 +7,19 @@ nonisolated struct SourceCapabilities: Hashable, Sendable, Codable {
|
||||
var canScan: Bool = true
|
||||
var canMaterializeItems: Bool = true
|
||||
var canExportPortablePackages: Bool = true
|
||||
var canInstallItems: Bool = false
|
||||
|
||||
static let localFolder = SourceCapabilities(
|
||||
canScan: true,
|
||||
canMaterializeItems: true,
|
||||
canExportPortablePackages: true
|
||||
canExportPortablePackages: true,
|
||||
canInstallItems: true
|
||||
)
|
||||
|
||||
static let connectedDevice = SourceCapabilities(
|
||||
canScan: true,
|
||||
canMaterializeItems: true,
|
||||
canExportPortablePackages: true
|
||||
canExportPortablePackages: true,
|
||||
canInstallItems: true
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
nonisolated private static func resolvedContentRootPath(
|
||||
nonisolated static func resolvedContentRootPath(
|
||||
in entries: [ZipArchiveEntry],
|
||||
archivePathExtension: String
|
||||
) throws -> String {
|
||||
@ -181,7 +181,7 @@ enum MinecraftPackageInspector {
|
||||
return root
|
||||
}
|
||||
|
||||
nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
||||
nonisolated static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
||||
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
||||
|
||||
let worldMarkers = ["level.dat", "levelname.txt"]
|
||||
|
||||
@ -11,12 +11,14 @@ nonisolated struct ZipArchiveEntry: Sendable, Hashable {
|
||||
let uncompressedSize: UInt32
|
||||
let localHeaderOffset: UInt32
|
||||
let isDirectory: Bool
|
||||
let isSymbolicLink: Bool
|
||||
}
|
||||
|
||||
nonisolated enum ZipArchiveReaderError: LocalizedError {
|
||||
case invalidArchive
|
||||
case unsupportedCompressionMethod(UInt16)
|
||||
case unsupportedFeatures(String)
|
||||
case unsafeEntryPath(String)
|
||||
case entryNotFound(String)
|
||||
case decompressionFailed(Int32)
|
||||
|
||||
@ -28,6 +30,8 @@ nonisolated enum ZipArchiveReaderError: LocalizedError {
|
||||
return "Unsupported ZIP compression method: \(method)."
|
||||
case .unsupportedFeatures(let message):
|
||||
return message
|
||||
case .unsafeEntryPath(let path):
|
||||
return "The ZIP archive contains an unsafe entry path: \(path)"
|
||||
case .entryNotFound(let path):
|
||||
return "ZIP entry not found: \(path)"
|
||||
case .decompressionFailed(let code):
|
||||
@ -41,7 +45,7 @@ nonisolated struct ZipArchiveReader {
|
||||
let entries: [ZipArchiveEntry]
|
||||
|
||||
init(url: URL) throws {
|
||||
self.data = try Data(contentsOf: url)
|
||||
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
||||
}
|
||||
|
||||
@ -78,6 +82,9 @@ nonisolated struct ZipArchiveReader {
|
||||
|
||||
switch entry.compressionMethod {
|
||||
case 0:
|
||||
guard compressedData.count == Int(entry.uncompressedSize) else {
|
||||
throw ZipArchiveReaderError.invalidArchive
|
||||
}
|
||||
return compressedData
|
||||
case 8:
|
||||
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
|
||||
@ -115,6 +122,7 @@ nonisolated struct ZipArchiveReader {
|
||||
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
|
||||
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
|
||||
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
|
||||
let externalAttributes = data.readUInt32LE(at: offset + 38)
|
||||
|
||||
let filenameStart = offset + 46
|
||||
let filenameEnd = filenameStart + filenameLength
|
||||
@ -127,7 +135,9 @@ nonisolated struct ZipArchiveReader {
|
||||
throw ZipArchiveReaderError.invalidArchive
|
||||
}
|
||||
|
||||
try validateEntryPath(filename)
|
||||
let normalizedPath = Self.normalizedPath(filename)
|
||||
let unixMode = UInt16((externalAttributes >> 16) & 0xffff)
|
||||
entries.append(
|
||||
ZipArchiveEntry(
|
||||
path: normalizedPath,
|
||||
@ -135,7 +145,8 @@ nonisolated struct ZipArchiveReader {
|
||||
compressedSize: compressedSize,
|
||||
uncompressedSize: uncompressedSize,
|
||||
localHeaderOffset: localHeaderOffset,
|
||||
isDirectory: normalizedPath.hasSuffix("/")
|
||||
isDirectory: normalizedPath.hasSuffix("/"),
|
||||
isSymbolicLink: unixMode & 0o170000 == 0o120000
|
||||
)
|
||||
)
|
||||
|
||||
@ -178,6 +189,18 @@ nonisolated struct ZipArchiveReader {
|
||||
return joined
|
||||
}
|
||||
|
||||
private static func validateEntryPath(_ path: String) throws {
|
||||
let replaced = path.replacingOccurrences(of: "\\", with: "/")
|
||||
let components = replaced.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard
|
||||
!replaced.hasPrefix("/"),
|
||||
!replaced.contains("\0"),
|
||||
!components.contains(where: { $0 == ".." })
|
||||
else {
|
||||
throw ZipArchiveReaderError.unsafeEntryPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
|
||||
if data.isEmpty {
|
||||
return Data()
|
||||
@ -228,11 +251,17 @@ nonisolated struct ZipArchiveReader {
|
||||
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
|
||||
if producedByteCount > 0 {
|
||||
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
|
||||
guard output.count <= expectedSize else {
|
||||
throw ZipArchiveReaderError.invalidArchive
|
||||
}
|
||||
}
|
||||
}
|
||||
} while status != Z_STREAM_END
|
||||
}
|
||||
|
||||
guard output.count == expectedSize else {
|
||||
throw ZipArchiveReaderError.invalidArchive
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
}
|
||||
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||
@Published var isRestoringPersistedSources = true
|
||||
@Published var installationStateBySourceID: [URL: SourceInstallationState] = [:]
|
||||
|
||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
||||
@ -250,6 +251,143 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
}
|
||||
}
|
||||
|
||||
func installPackages(at packageURLs: [URL], into sourceID: URL) async throws -> [InstalledContentItem] {
|
||||
guard
|
||||
let destinationSource = source(withID: sourceID),
|
||||
destinationSource.availability == .available,
|
||||
destinationSource.capabilities.canInstallItems
|
||||
else {
|
||||
throw SourceAccessError.accessFailed(reason: "This source is not currently available for installation.")
|
||||
}
|
||||
guard !packageURLs.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
scanTasks[sourceID]?.cancel()
|
||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||
completedCount: 0,
|
||||
totalCount: packageURLs.count,
|
||||
status: "Preparing import..."
|
||||
)
|
||||
|
||||
var plans: [InstallationPlan] = []
|
||||
var installedItems: [InstalledContentItem] = []
|
||||
var totalPayloadCount = 0
|
||||
do {
|
||||
for packageURL in packageURLs {
|
||||
let plan = try await Task.detached(priority: .userInitiated) {
|
||||
let accessedSecurityScope = packageURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessedSecurityScope {
|
||||
packageURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
return try MinecraftPackageInstaller.preparePackage(at: packageURL)
|
||||
}.value
|
||||
plans.append(plan)
|
||||
}
|
||||
|
||||
let payloads = plans.flatMap(\.payloads)
|
||||
totalPayloadCount = payloads.count
|
||||
try validatePackConflicts(payloads, destinationSource: destinationSource)
|
||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||
completedCount: 0,
|
||||
totalCount: payloads.count,
|
||||
status: payloads.count == 1 ? "Installing item..." : "Installing \(payloads.count) items..."
|
||||
)
|
||||
|
||||
for payload in payloads {
|
||||
guard let currentDestination = source(withID: sourceID) else {
|
||||
throw SourceAccessError.accessFailed(reason: "The destination source was removed during installation.")
|
||||
}
|
||||
let installedItem = try await sourceAccessMethod.install(payload, in: currentDestination)
|
||||
installedItems.append(installedItem)
|
||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
||||
completedCount: installedItems.count,
|
||||
totalCount: payloads.count,
|
||||
status: "Installed \(installedItems.count) of \(payloads.count)..."
|
||||
)
|
||||
}
|
||||
|
||||
plans.forEach { $0.cleanup() }
|
||||
installationStateBySourceID[sourceID] = nil
|
||||
startScan(for: sourceID, mode: .fullScan)
|
||||
return installedItems
|
||||
} catch {
|
||||
plans.forEach { $0.cleanup() }
|
||||
installationStateBySourceID[sourceID] = nil
|
||||
if !installedItems.isEmpty {
|
||||
startScan(for: sourceID, mode: .fullScan)
|
||||
throw MinecraftPackageInstaller.InstallError.partialInstallation(
|
||||
completed: installedItems.count,
|
||||
total: totalPayloadCount,
|
||||
reason: error.localizedDescription
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func copyItem(
|
||||
_ item: MinecraftContentItem,
|
||||
from sourceID: URL,
|
||||
into destinationSourceID: URL
|
||||
) async throws -> [InstalledContentItem] {
|
||||
guard let source = source(withID: sourceID) else {
|
||||
throw SourceAccessError.accessFailed(reason: "The source item is no longer available.")
|
||||
}
|
||||
|
||||
let representation = try await externalRepresentation(
|
||||
for: item,
|
||||
in: source,
|
||||
preferredKind: .portablePackage
|
||||
)
|
||||
defer {
|
||||
if representation.isTemporary {
|
||||
try? FileManager.default.removeItem(at: representation.url)
|
||||
}
|
||||
}
|
||||
return try await installPackages(at: [representation.url], into: destinationSourceID)
|
||||
}
|
||||
|
||||
private func validatePackConflicts(
|
||||
_ payloads: [InstallationPayload],
|
||||
destinationSource: MinecraftSource
|
||||
) throws {
|
||||
var existingIdentities = Set(
|
||||
destinationSource.rawItems.compactMap { item -> String? in
|
||||
guard
|
||||
item.contentType == .behaviorPack ||
|
||||
item.contentType == .resourcePack ||
|
||||
item.contentType == .skinPack,
|
||||
let uuid = item.packUUID?.lowercased()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return "\(item.contentType.rawValue)::\(uuid)"
|
||||
}
|
||||
)
|
||||
|
||||
for payload in payloads {
|
||||
guard
|
||||
payload.contentType == .behaviorPack ||
|
||||
payload.contentType == .resourcePack ||
|
||||
payload.contentType == .skinPack,
|
||||
let uuid = payload.packUUID?.lowercased()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let identity = "\(payload.contentType.rawValue)::\(uuid)"
|
||||
guard !existingIdentities.contains(identity) else {
|
||||
throw MinecraftPackageInstaller.InstallError.duplicatePack(
|
||||
name: payload.displayName,
|
||||
version: payload.packVersion
|
||||
)
|
||||
}
|
||||
existingIdentities.insert(identity)
|
||||
}
|
||||
}
|
||||
|
||||
func removeSource(withID sourceID: URL) {
|
||||
let removedSource = source(withID: sourceID)
|
||||
scanTasks[sourceID]?.cancel()
|
||||
|
||||
@ -120,7 +120,8 @@ enum SourcePresentation {
|
||||
case .discovering, .metadata, .previews:
|
||||
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
|
||||
case .sizing:
|
||||
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
|
||||
let remainingCount = max(source.indexedItemCount - source.sizeLoadedCount, 0)
|
||||
return "Calculating sizes for \(remainingCount) of \(source.indexedItemCount) items..."
|
||||
case .completed:
|
||||
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
|
||||
case .idle:
|
||||
|
||||
@ -227,23 +227,6 @@ enum SourceScanExecutor {
|
||||
}
|
||||
host.persistSourceIfAvailable(withID: sourceID)
|
||||
|
||||
if source.origin.kind == .connectedDevice {
|
||||
try await finishConnectedDeviceScan(
|
||||
sourceID: sourceID,
|
||||
source: source,
|
||||
host: host,
|
||||
sourceAccessMethod: sourceAccessMethod,
|
||||
notificationService: notificationService,
|
||||
index: index,
|
||||
discoveredCount: discoveredCount,
|
||||
scanStartTime: scanStartTime,
|
||||
scanContextURL: scanContextURL,
|
||||
performanceContext: performanceContext,
|
||||
minimumVisibleScanDuration: minimumVisibleScanDuration
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let sizeQueue = EnrichmentWorkQueue()
|
||||
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
|
||||
Task.detached(priority: .utility) {
|
||||
@ -322,52 +305,6 @@ enum SourceScanExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private static func finishConnectedDeviceScan(
|
||||
sourceID: URL,
|
||||
source: MinecraftSource,
|
||||
host: SourceScanSessionHosting,
|
||||
sourceAccessMethod: SourceAccessMethod,
|
||||
notificationService: ScanNotificationServicing,
|
||||
index: SourceIndexActor,
|
||||
discoveredCount: Int,
|
||||
scanStartTime: Date,
|
||||
scanContextURL: URL,
|
||||
performanceContext: String,
|
||||
minimumVisibleScanDuration: TimeInterval
|
||||
) async throws {
|
||||
let sizeStageStartTime = Date()
|
||||
let sizeSeedItems = await index.currentItems()
|
||||
let sizedItems = await sourceAccessMethod.loadSizeAssets(
|
||||
for: sizeSeedItems.filter { !$0.sizeLoaded },
|
||||
in: source
|
||||
)
|
||||
for sizedItem in sizedItems {
|
||||
if let snapshot = await index.applySizedItem(sizedItem) {
|
||||
host.applySnapshot(snapshot, to: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
host.logScanStage(
|
||||
"Size",
|
||||
elapsed: Date().timeIntervalSince(sizeStageStartTime),
|
||||
context: performanceContext,
|
||||
itemCount: discoveredCount
|
||||
)
|
||||
|
||||
try await finishScan(
|
||||
sourceID: sourceID,
|
||||
source: source,
|
||||
host: host,
|
||||
notificationService: notificationService,
|
||||
index: index,
|
||||
discoveredCount: discoveredCount,
|
||||
scanStartTime: scanStartTime,
|
||||
scanContextURL: scanContextURL,
|
||||
performanceContext: performanceContext,
|
||||
minimumVisibleScanDuration: minimumVisibleScanDuration
|
||||
)
|
||||
}
|
||||
|
||||
private static func finishScan(
|
||||
sourceID: URL,
|
||||
source: MinecraftSource,
|
||||
@ -813,7 +750,8 @@ private actor SourceIndexActor {
|
||||
} else if sizeLoadedCount == 0 {
|
||||
scanStatus = "Preparing size calculations..."
|
||||
} else {
|
||||
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
|
||||
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
|
||||
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
|
||||
}
|
||||
} else {
|
||||
scanStatus = indexedItemCount == 0
|
||||
|
||||
@ -177,6 +177,37 @@ enum AppleMobileDeviceAccess {
|
||||
}
|
||||
}
|
||||
|
||||
static func installDirectory(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
minecraftRootRelativePath: String,
|
||||
collectionFolderName: String,
|
||||
preferredDestinationName: String,
|
||||
sourceDirectoryURL: URL
|
||||
) async throws -> String {
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let installedName = WMMInstallLocalDirectoryInConnectedDeviceApp(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
minecraftRootRelativePath,
|
||||
collectionFolderName,
|
||||
preferredDestinationName,
|
||||
sourceDirectoryURL,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 16,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice content installation failed."]
|
||||
)
|
||||
}
|
||||
return installedName
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
|
||||
@ -98,4 +98,15 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSString * _Nullable
|
||||
WMMInstallLocalDirectoryInConnectedDeviceApp(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *minecraftRootRelativePath,
|
||||
NSString *collectionFolderName,
|
||||
NSString *preferredDestinationName,
|
||||
NSURL *sourceDirectoryURL,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@ -122,7 +122,11 @@ typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **val
|
||||
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
||||
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
||||
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
||||
typedef int (*AFCFileRefWriteFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, const void *buffer, uint32_t length);
|
||||
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
||||
typedef int (*AFCDirectoryCreateFn)(AFCConnectionRef connection, const char *path);
|
||||
typedef int (*AFCRenamePathFn)(AFCConnectionRef connection, const char *sourcePath, const char *destinationPath);
|
||||
typedef int (*AFCRemovePathFn)(AFCConnectionRef connection, const char *path);
|
||||
|
||||
typedef struct {
|
||||
void *handle;
|
||||
@ -159,7 +163,11 @@ typedef struct {
|
||||
AFCKeyValueCloseFn AFCKeyValueClose;
|
||||
AFCFileRefOpenFn AFCFileRefOpen;
|
||||
AFCFileRefReadFn AFCFileRefRead;
|
||||
AFCFileRefWriteFn AFCFileRefWrite;
|
||||
AFCFileRefCloseFn AFCFileRefClose;
|
||||
AFCDirectoryCreateFn AFCDirectoryCreate;
|
||||
AFCRenamePathFn AFCRenamePath;
|
||||
AFCRemovePathFn AFCRemovePath;
|
||||
} WMMMobileDeviceFunctions;
|
||||
|
||||
typedef struct {
|
||||
@ -237,7 +245,11 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
||||
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
||||
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
||||
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
||||
functions->AFCFileRefWrite = (AFCFileRefWriteFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefWrite");
|
||||
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
||||
functions->AFCDirectoryCreate = (AFCDirectoryCreateFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryCreate");
|
||||
functions->AFCRenamePath = (AFCRenamePathFn)WMMLoadSymbol(frameworkHandle, "AFCRenamePath");
|
||||
functions->AFCRemovePath = (AFCRemovePathFn)WMMLoadSymbol(frameworkHandle, "AFCRemovePath");
|
||||
|
||||
if (functions->AMDeviceNotificationSubscribe == NULL ||
|
||||
functions->AMDeviceNotificationUnsubscribe == NULL ||
|
||||
@ -1036,6 +1048,163 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
||||
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
||||
}
|
||||
|
||||
static BOOL WMMEnsureAFCDirectory(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath,
|
||||
NSError **error
|
||||
) {
|
||||
NSMutableArray<NSString *> *existingEntries = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &existingEntries) == 0) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
const int createStatus = functions->AFCDirectoryCreate(
|
||||
afcConnection,
|
||||
remotePath.fileSystemRepresentation
|
||||
);
|
||||
if (createStatus != 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(
|
||||
createStatus,
|
||||
[NSString stringWithFormat:@"AFCDirectoryCreate failed for %@ (%d).", remotePath, createStatus]
|
||||
);
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL WMMCopyLocalFileToAFCPath(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSURL *localFileURL,
|
||||
NSString *remotePath,
|
||||
NSError **error
|
||||
) {
|
||||
AFCFileDescriptorRef fileDescriptor = NULL;
|
||||
const int openStatus = functions->AFCFileRefOpen(
|
||||
afcConnection,
|
||||
remotePath.fileSystemRepresentation,
|
||||
3,
|
||||
&fileDescriptor
|
||||
);
|
||||
if (openStatus != 0 || fileDescriptor == NULL) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(
|
||||
openStatus,
|
||||
[NSString stringWithFormat:@"AFCFileRefOpen for writing failed for %@ (%d).", remotePath, openStatus]
|
||||
);
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSFileHandle *handle = [NSFileHandle fileHandleForReadingFromURL:localFileURL error:error];
|
||||
if (handle == nil) {
|
||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||
return NO;
|
||||
}
|
||||
|
||||
BOOL success = YES;
|
||||
while (true) {
|
||||
@autoreleasepool {
|
||||
NSData *chunk = [handle readDataOfLength:64 * 1024];
|
||||
if (chunk.length == 0) {
|
||||
break;
|
||||
}
|
||||
const int writeStatus = functions->AFCFileRefWrite(
|
||||
afcConnection,
|
||||
fileDescriptor,
|
||||
chunk.bytes,
|
||||
(uint32_t)chunk.length
|
||||
);
|
||||
if (writeStatus != 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(
|
||||
writeStatus,
|
||||
[NSString stringWithFormat:@"AFCFileRefWrite failed for %@ (%d).", remotePath, writeStatus]
|
||||
);
|
||||
}
|
||||
success = NO;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[handle closeFile];
|
||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||
return success;
|
||||
}
|
||||
|
||||
static BOOL WMMCopyLocalTreeToAFCPath(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSURL *localURL,
|
||||
NSString *remotePath,
|
||||
NSError **error
|
||||
) {
|
||||
NSNumber *isDirectory = nil;
|
||||
if (![localURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:error]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (!isDirectory.boolValue) {
|
||||
return WMMCopyLocalFileToAFCPath(functions, afcConnection, localURL, remotePath, error);
|
||||
}
|
||||
|
||||
if (!WMMEnsureAFCDirectory(functions, afcConnection, remotePath, error)) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSArray<NSURL *> *children = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:localURL
|
||||
includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey]
|
||||
options:0
|
||||
error:error];
|
||||
if (children == nil) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
for (NSURL *childURL in children) {
|
||||
NSNumber *isSymbolicLink = nil;
|
||||
if (![childURL getResourceValue:&isSymbolicLink forKey:NSURLIsSymbolicLinkKey error:error]) {
|
||||
return NO;
|
||||
}
|
||||
if (isSymbolicLink.boolValue) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(17, [NSString stringWithFormat:@"Symbolic links cannot be installed: %@", childURL.path]);
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSString *childRemotePath = [remotePath stringByAppendingPathComponent:childURL.lastPathComponent];
|
||||
if (!WMMCopyLocalTreeToAFCPath(functions, afcConnection, childURL, childRemotePath, error)) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL WMMRemoveAFCTree(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath
|
||||
) {
|
||||
NSMutableArray<NSString *> *entries = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries) == 0) {
|
||||
for (NSString *entry in entries) {
|
||||
if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) {
|
||||
continue;
|
||||
}
|
||||
WMMRemoveAFCTree(
|
||||
functions,
|
||||
afcConnection,
|
||||
[remotePath stringByAppendingPathComponent:entry]
|
||||
);
|
||||
}
|
||||
}
|
||||
return functions->AFCRemovePath(afcConnection, remotePath.fileSystemRepresentation) == 0;
|
||||
}
|
||||
|
||||
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
||||
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
||||
if (![normalizedPath hasPrefix:@"/"]) {
|
||||
@ -2381,3 +2550,122 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
NSString * _Nullable
|
||||
WMMInstallLocalDirectoryInConnectedDeviceApp(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *minecraftRootRelativePath,
|
||||
NSString *collectionFolderName,
|
||||
NSString *preferredDestinationName,
|
||||
NSURL *sourceDirectoryURL,
|
||||
NSError **error
|
||||
) {
|
||||
if (bundleIdentifier.length == 0 ||
|
||||
minecraftRootRelativePath.length == 0 ||
|
||||
collectionFolderName.length == 0 ||
|
||||
preferredDestinationName.length == 0 ||
|
||||
sourceDirectoryURL == nil ||
|
||||
!sourceDirectoryURL.isFileURL) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(16, @"A device, Minecraft path, collection, destination name, and local source directory are required.");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
if (functions.AFCFileRefWrite == NULL ||
|
||||
functions.AFCDirectoryCreate == NULL ||
|
||||
functions.AFCRenamePath == NULL ||
|
||||
functions.AFCRemovePath == NULL) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(17, @"This version of MobileDevice.framework does not provide the required AFC write operations.");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
&functions,
|
||||
device,
|
||||
bundleIdentifier,
|
||||
&backingServiceConnection,
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *minecraftRoot = WMMNormalizedAFCPath(minecraftRootRelativePath);
|
||||
NSString *stagingRoot = [minecraftRoot stringByAppendingPathComponent:@".world-manager-staging"];
|
||||
NSString *stagedItemPath = [stagingRoot stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
|
||||
NSString *collectionPath = [minecraftRoot stringByAppendingPathComponent:collectionFolderName];
|
||||
|
||||
BOOL success =
|
||||
WMMEnsureAFCDirectory(&functions, afcConnection, stagingRoot, error) &&
|
||||
WMMEnsureAFCDirectory(&functions, afcConnection, stagedItemPath, error) &&
|
||||
WMMCopyLocalTreeToAFCPath(&functions, afcConnection, sourceDirectoryURL, stagedItemPath, error) &&
|
||||
WMMEnsureAFCDirectory(&functions, afcConnection, collectionPath, error);
|
||||
|
||||
NSString *destinationName = nil;
|
||||
if (success) {
|
||||
NSMutableArray<NSString *> *existingEntries = nil;
|
||||
const int listStatus = WMMReadAFCDirectory(&functions, afcConnection, collectionPath, &existingEntries);
|
||||
if (listStatus != 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(
|
||||
listStatus,
|
||||
[NSString stringWithFormat:@"Could not list the destination collection %@ (%d).", collectionPath, listStatus]
|
||||
);
|
||||
}
|
||||
success = NO;
|
||||
} else {
|
||||
NSSet<NSString *> *existingNames = [NSSet setWithArray:existingEntries ?: @[]];
|
||||
destinationName = preferredDestinationName;
|
||||
NSUInteger suffix = 2;
|
||||
while ([existingNames containsObject:destinationName]) {
|
||||
destinationName = [NSString stringWithFormat:@"%@-%lu", preferredDestinationName, (unsigned long)suffix];
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
NSString *destinationPath = [collectionPath stringByAppendingPathComponent:destinationName];
|
||||
const int renameStatus = functions.AFCRenamePath(
|
||||
afcConnection,
|
||||
stagedItemPath.fileSystemRepresentation,
|
||||
destinationPath.fileSystemRepresentation
|
||||
);
|
||||
if (renameStatus != 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(
|
||||
renameStatus,
|
||||
[NSString stringWithFormat:@"AFCRenamePath failed from %@ to %@ (%d).", stagedItemPath, destinationPath, renameStatus]
|
||||
);
|
||||
}
|
||||
success = NO;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
WMMRemoveAFCTree(&functions, afcConnection, stagedItemPath);
|
||||
destinationName = nil;
|
||||
}
|
||||
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return destinationName;
|
||||
}
|
||||
|
||||
@ -365,6 +365,31 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
throw SourceAccessError.accessFailed(reason: "The selected source is not a connected device.")
|
||||
}
|
||||
let minecraftRoot = container.minecraftFolderRelativePath?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !minecraftRoot.isEmpty else {
|
||||
throw SourceAccessError.accessFailed(reason: "The connected device is missing its Minecraft content path.")
|
||||
}
|
||||
|
||||
let destinationName = try await AppleMobileDeviceAccess.installDirectory(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
minecraftRootRelativePath: minecraftRoot,
|
||||
collectionFolderName: payload.contentType.collectionFolderName,
|
||||
preferredDestinationName: payload.suggestedFolderName,
|
||||
sourceDirectoryURL: payload.preparedDirectoryURL
|
||||
)
|
||||
return InstalledContentItem(
|
||||
contentType: payload.contentType,
|
||||
displayName: payload.displayName,
|
||||
destinationName: destinationName
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
guard source.origin.kind == .connectedDevice else {
|
||||
return
|
||||
|
||||
@ -25,6 +25,7 @@ protocol SourceAccessMethod: Sendable {
|
||||
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry]
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
|
||||
}
|
||||
|
||||
@ -108,6 +109,12 @@ extension SourceAccessMethod {
|
||||
return item.folderURL
|
||||
}
|
||||
|
||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
||||
_ = payload
|
||||
_ = source
|
||||
throw SourceAccessError.accessFailed(reason: "This source does not support installing Minecraft content.")
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
_ = source
|
||||
}
|
||||
@ -204,6 +211,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
||||
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
||||
}
|
||||
|
||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
||||
return try await accessMethod(for: source).install(payload, in: source)
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
||||
}
|
||||
|
||||
@ -133,6 +133,97 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
||||
return item.folderURL
|
||||
}
|
||||
|
||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
||||
let sourceRootURL = try resolvedSourceRootURL(for: source)
|
||||
let accessedSecurityScope = sourceRootURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessedSecurityScope {
|
||||
sourceRootURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let contentRootURL = try resolvedContentRootURL(for: source, sourceRootURL: sourceRootURL)
|
||||
let collectionURL = contentRootURL.appendingPathComponent(
|
||||
payload.contentType.collectionFolderName,
|
||||
isDirectory: true
|
||||
)
|
||||
let stagingRootURL = contentRootURL
|
||||
.appendingPathComponent(".world-manager-staging", isDirectory: true)
|
||||
let stagedItemURL = stagingRootURL
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
|
||||
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
||||
do {
|
||||
try fileManager.copyItem(at: payload.preparedDirectoryURL, to: stagedItemURL)
|
||||
try fileManager.createDirectory(at: collectionURL, withIntermediateDirectories: true)
|
||||
let destinationURL = uniqueDestinationURL(
|
||||
in: collectionURL,
|
||||
preferredName: payload.suggestedFolderName,
|
||||
fileManager: fileManager
|
||||
)
|
||||
try fileManager.moveItem(at: stagedItemURL, to: destinationURL)
|
||||
return InstalledContentItem(
|
||||
contentType: payload.contentType,
|
||||
displayName: payload.displayName,
|
||||
destinationName: destinationURL.lastPathComponent
|
||||
)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: stagedItemURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func resolvedSourceRootURL(for source: MinecraftSource) throws -> URL {
|
||||
guard case .localFolder(let bookmarkData) = source.origin, let bookmarkData else {
|
||||
return source.folderURL
|
||||
}
|
||||
|
||||
var isStale = false
|
||||
return try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
).standardizedFileURL
|
||||
}
|
||||
|
||||
nonisolated private func resolvedContentRootURL(
|
||||
for source: MinecraftSource,
|
||||
sourceRootURL: URL
|
||||
) throws -> URL {
|
||||
let observedRoots = Set(source.rawItems.map {
|
||||
$0.collectionRootURL.deletingLastPathComponent().standardizedFileURL
|
||||
})
|
||||
if let minimumDepth = observedRoots.map(\.pathComponents.count).min() {
|
||||
let shallowestRoots = observedRoots.filter { $0.pathComponents.count == minimumDepth }
|
||||
if shallowestRoots.count == 1, let observedRoot = shallowestRoots.first {
|
||||
return observedRoot
|
||||
}
|
||||
if shallowestRoots.count > 1 {
|
||||
throw SourceAccessError.accessFailed(
|
||||
reason: "This source contains more than one Minecraft content root. Choose the specific com.mojang folder before importing."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return sourceRootURL
|
||||
}
|
||||
|
||||
nonisolated private func uniqueDestinationURL(
|
||||
in collectionURL: URL,
|
||||
preferredName: String,
|
||||
fileManager: FileManager
|
||||
) -> URL {
|
||||
var candidateURL = collectionURL.appendingPathComponent(preferredName, isDirectory: true)
|
||||
var suffix = 2
|
||||
while fileManager.fileExists(atPath: candidateURL.path) {
|
||||
candidateURL = collectionURL.appendingPathComponent("\(preferredName)-\(suffix)", isDirectory: true)
|
||||
suffix += 1
|
||||
}
|
||||
return candidateURL
|
||||
}
|
||||
|
||||
nonisolated private func discoverItemsByReconcilingCache(
|
||||
for source: MinecraftSource,
|
||||
snapshot: SourceSnapshot,
|
||||
|
||||
@ -7,6 +7,7 @@ import SwiftUI
|
||||
struct ItemDetailColumnView: View {
|
||||
let item: MinecraftContentItem?
|
||||
let source: MinecraftSource?
|
||||
let installationState: SourceInstallationState?
|
||||
let showsSourceDetails: Bool
|
||||
let behaviorPacks: [ContentPackReference]
|
||||
let resourcePacks: [ContentPackReference]
|
||||
@ -22,6 +23,7 @@ struct ItemDetailColumnView: View {
|
||||
let exportAction: () -> Void
|
||||
let revealAction: () -> Void
|
||||
let shareAction: (NSView?) -> Void
|
||||
let importAction: (MinecraftSource) -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@ -45,7 +47,13 @@ struct ItemDetailColumnView: View {
|
||||
shareAction: shareAction
|
||||
)
|
||||
} else if showsSourceDetails, let source {
|
||||
SourceDetailView(source: source)
|
||||
SourceDetailView(
|
||||
source: source,
|
||||
installationState: installationState,
|
||||
importAction: {
|
||||
importAction(source)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text("Select a world or pack to see details")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@ -20,13 +20,38 @@ struct SourceDetailView: View {
|
||||
}
|
||||
|
||||
let source: MinecraftSource
|
||||
let installationState: SourceInstallationState?
|
||||
let importAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(source.displayName)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Import...", action: importAction)
|
||||
.disabled(
|
||||
source.availability != .available ||
|
||||
!source.capabilities.canInstallItems ||
|
||||
installationState != nil
|
||||
)
|
||||
}
|
||||
|
||||
if let installationState {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView(
|
||||
value: Double(installationState.completedCount),
|
||||
total: Double(max(installationState.totalCount, 1))
|
||||
)
|
||||
Text(installationState.status)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
.appDetailSectionCard()
|
||||
}
|
||||
|
||||
if showsStatusSection {
|
||||
sourceStatusSection
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
let searchPrompt: String
|
||||
let chooseFolderAction: () -> Void
|
||||
let dropAction: ([NSItemProvider]) -> Bool
|
||||
let dragProvider: (MinecraftContentItem) -> NSItemProvider
|
||||
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||
|
||||
var body: some View {
|
||||
@ -55,6 +56,14 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
List(items, selection: $selectedItemID) { item in
|
||||
ContentRowView(item: item)
|
||||
.tag(item.id)
|
||||
.onDrag {
|
||||
dragProvider(item)
|
||||
}
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
selectedItemID = item.id
|
||||
}
|
||||
)
|
||||
.contextMenu {
|
||||
itemContextMenu(item)
|
||||
}
|
||||
|
||||
@ -262,6 +262,9 @@ struct SidebarColumnPreviewContainer: View {
|
||||
addConnectedDeviceAction: { _ in },
|
||||
rescanSourceAction: { _ in },
|
||||
removeSourceAction: { _ in },
|
||||
importSourceAction: { _ in },
|
||||
importDropAction: { _, _ in false },
|
||||
installationState: { _ in nil },
|
||||
filters: { source in
|
||||
let allFilter = SidebarFilter(
|
||||
title: "All Content",
|
||||
@ -331,6 +334,7 @@ struct ItemListColumnPreviewContainer: View {
|
||||
searchPrompt: "Search Worlds",
|
||||
chooseFolderAction: {},
|
||||
dropAction: { _ in false },
|
||||
dragProvider: { _ in NSItemProvider() },
|
||||
itemContextMenu: { item in
|
||||
Button("Reveal \(item.displayName)") {}
|
||||
}
|
||||
@ -345,6 +349,7 @@ struct ItemDetailColumnPreviewContainer: View {
|
||||
ItemDetailColumnView(
|
||||
item: PreviewFixtures.featuredWorld,
|
||||
source: PreviewFixtures.primarySource,
|
||||
installationState: nil,
|
||||
showsSourceDetails: false,
|
||||
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
||||
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
||||
@ -359,7 +364,8 @@ struct ItemDetailColumnPreviewContainer: View {
|
||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||
exportAction: {},
|
||||
revealAction: {},
|
||||
shareAction: { _ in }
|
||||
shareAction: { _ in },
|
||||
importAction: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ struct ContentView: View {
|
||||
@State private var isDropTargeted = false
|
||||
@State private var isPerformingItemAction = false
|
||||
@State private var isShowingDeviceSourceSheet = false
|
||||
@State private var importErrorMessage: String?
|
||||
@State private var sortMode: ItemSortMode = .name
|
||||
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
||||
@State private var showsProjectionLoadingState = false
|
||||
@ -87,6 +88,11 @@ struct ContentView: View {
|
||||
removeSourceAction: { source in
|
||||
removeSource(source.id)
|
||||
},
|
||||
importSourceAction: importIntoSource(_:),
|
||||
importDropAction: handleImportDrop(on:providers:),
|
||||
installationState: { source in
|
||||
library.installationStateBySourceID[source.id]
|
||||
},
|
||||
filters: sidebarFilters(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||
@ -109,6 +115,7 @@ struct ContentView: View {
|
||||
searchPrompt: resolvedItemListProjection.searchPrompt,
|
||||
chooseFolderAction: pickFolder,
|
||||
dropAction: handleDroppedProviders(_:),
|
||||
dragProvider: dragProvider(for:),
|
||||
itemContextMenu: itemContextMenu(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||
@ -116,6 +123,7 @@ struct ContentView: View {
|
||||
ItemDetailColumnView(
|
||||
item: resolvedCurrentSelectedItem,
|
||||
source: resolvedCurrentSource,
|
||||
installationState: resolvedCurrentSource.flatMap { library.installationStateBySourceID[$0.id] },
|
||||
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
||||
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
@ -148,7 +156,8 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
shareItem(item, from: anchorView)
|
||||
}
|
||||
},
|
||||
importAction: importIntoSource(_:)
|
||||
)
|
||||
.frame(minWidth: 450)
|
||||
}
|
||||
@ -169,6 +178,23 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert(
|
||||
"Import Failed",
|
||||
isPresented: Binding(
|
||||
get: { importErrorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
importErrorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Button("OK") {
|
||||
importErrorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(importErrorMessage ?? "The Minecraft content could not be imported.")
|
||||
}
|
||||
.task {
|
||||
AppTerminationCoordinator.shared.register(library: library)
|
||||
}
|
||||
@ -526,6 +552,166 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func importIntoSource(_ source: MinecraftSource) {
|
||||
guard source.availability == .available, source.capabilities.canInstallItems else {
|
||||
return
|
||||
}
|
||||
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.title = "Import Minecraft Content into \(source.displayName)"
|
||||
panel.prompt = "Import"
|
||||
panel.allowedContentTypes = [
|
||||
.minecraftWorld,
|
||||
.minecraftPack,
|
||||
.minecraftTemplate,
|
||||
.minecraftAddon
|
||||
]
|
||||
|
||||
guard panel.runModal() == .OK else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await performImport(packageURLs: panel.urls, into: source.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImportDrop(
|
||||
on source: MinecraftSource,
|
||||
providers: [NSItemProvider]
|
||||
) -> Bool {
|
||||
guard
|
||||
source.availability == .available,
|
||||
source.capabilities.canInstallItems,
|
||||
providers.contains(where: isSupportedImportProvider(_:))
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
Task {
|
||||
let materializedDrop = await materializeDroppedPackages(from: providers)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: materializedDrop.temporaryRootURL)
|
||||
}
|
||||
guard !materializedDrop.packageURLs.isEmpty else {
|
||||
await MainActor.run {
|
||||
importErrorMessage = "The dropped items did not contain a supported .mcworld, .mcpack, .mctemplate, or .mcaddon file."
|
||||
}
|
||||
return
|
||||
}
|
||||
await performImport(packageURLs: materializedDrop.packageURLs, into: source.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performImport(packageURLs: [URL], into sourceID: URL) async {
|
||||
do {
|
||||
_ = try await library.installPackages(at: packageURLs, into: sourceID)
|
||||
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||
selectedItemID = nil
|
||||
} catch {
|
||||
importErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func isSupportedImportProvider(_ provider: NSItemProvider) -> Bool {
|
||||
importTypeIdentifiers.contains {
|
||||
provider.hasItemConformingToTypeIdentifier($0)
|
||||
}
|
||||
}
|
||||
|
||||
private var importTypeIdentifiers: [String] {
|
||||
[
|
||||
UTType.minecraftWorld.identifier,
|
||||
UTType.minecraftPack.identifier,
|
||||
UTType.minecraftTemplate.identifier,
|
||||
UTType.minecraftAddon.identifier,
|
||||
UTType.fileURL.identifier
|
||||
]
|
||||
}
|
||||
|
||||
private func materializeDroppedPackages(
|
||||
from providers: [NSItemProvider]
|
||||
) async -> (packageURLs: [URL], temporaryRootURL: URL) {
|
||||
let temporaryRootURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("MinecraftImportDrop", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true)
|
||||
|
||||
var packageURLs: [URL] = []
|
||||
for provider in providers where isSupportedImportProvider(provider) {
|
||||
if let packageURL = await materializeDroppedPackage(
|
||||
from: provider,
|
||||
in: temporaryRootURL
|
||||
) {
|
||||
packageURLs.append(packageURL)
|
||||
}
|
||||
}
|
||||
return (packageURLs, temporaryRootURL)
|
||||
}
|
||||
|
||||
private func materializeDroppedPackage(
|
||||
from provider: NSItemProvider,
|
||||
in temporaryRootURL: URL
|
||||
) async -> URL? {
|
||||
let packageDefinitions = MinecraftPackageTypes.all
|
||||
if let definition = packageDefinitions.first(where: {
|
||||
provider.hasItemConformingToTypeIdentifier($0.utTypeIdentifier)
|
||||
}) {
|
||||
return await withCheckedContinuation { continuation in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: definition.utTypeIdentifier) { url, _ in
|
||||
guard let url else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
let baseName = provider.suggestedName.map {
|
||||
URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent
|
||||
} ?? UUID().uuidString
|
||||
let destinationURL = temporaryRootURL
|
||||
.appendingPathComponent(baseName)
|
||||
.appendingPathExtension(definition.pathExtension)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: url, to: destinationURL)
|
||||
continuation.resume(returning: destinationURL)
|
||||
} catch {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
|
||||
return nil
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
|
||||
guard
|
||||
let data,
|
||||
let sourceURL = NSURL(
|
||||
absoluteURLWithDataRepresentation: data,
|
||||
relativeTo: nil
|
||||
) as URL?,
|
||||
MinecraftPackageInspector.supportedPathExtensions.contains(
|
||||
sourceURL.pathExtension.lowercased()
|
||||
)
|
||||
else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
let destinationURL = temporaryRootURL.appendingPathComponent(sourceURL.lastPathComponent)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||
continuation.resume(returning: destinationURL)
|
||||
} catch {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||
let fileURLType = UTType.fileURL.identifier
|
||||
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SidebarSelection: Hashable, Sendable {
|
||||
case source(sourceID: URL)
|
||||
@ -33,6 +34,9 @@ struct SourcesSidebarView: View {
|
||||
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||
let rescanSourceAction: (MinecraftSource) -> Void
|
||||
let removeSourceAction: (MinecraftSource) -> Void
|
||||
let importSourceAction: (MinecraftSource) -> Void
|
||||
let importDropAction: (MinecraftSource, [NSItemProvider]) -> Bool
|
||||
let installationState: (MinecraftSource) -> SourceInstallationState?
|
||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||
|
||||
var body: some View {
|
||||
@ -81,8 +85,12 @@ struct SourcesSidebarView: View {
|
||||
|
||||
SourceHeaderRow(
|
||||
source: source,
|
||||
installationState: installationState(source),
|
||||
onSelect: {
|
||||
selection = .source(sourceID: source.id)
|
||||
},
|
||||
dropAction: { providers in
|
||||
importDropAction(source, providers)
|
||||
}
|
||||
)
|
||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
||||
@ -93,6 +101,11 @@ struct SourcesSidebarView: View {
|
||||
rescanSourceAction(source)
|
||||
}
|
||||
|
||||
Button("Import into \"\(source.displayName)\"...") {
|
||||
importSourceAction(source)
|
||||
}
|
||||
.disabled(source.availability != .available || !source.capabilities.canInstallItems)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||
@ -150,7 +163,10 @@ private struct SidebarSourcesSectionHeaderView: View {
|
||||
|
||||
private struct SourceHeaderRow: View {
|
||||
let source: MinecraftSource
|
||||
let installationState: SourceInstallationState?
|
||||
let onSelect: () -> Void
|
||||
let dropAction: ([NSItemProvider]) -> Bool
|
||||
@State private var isDropTargeted = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
@ -179,8 +195,23 @@ private struct SourceHeaderRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 5)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
isDropTargeted ? Color.accentColor.opacity(0.16) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onSelect)
|
||||
.onDrop(
|
||||
of: [
|
||||
UTType.fileURL.identifier,
|
||||
UTType.minecraftWorld.identifier,
|
||||
UTType.minecraftPack.identifier,
|
||||
UTType.minecraftTemplate.identifier,
|
||||
UTType.minecraftAddon.identifier
|
||||
],
|
||||
isTargeted: $isDropTargeted,
|
||||
perform: dropAction
|
||||
)
|
||||
}
|
||||
|
||||
private var connection: DeviceConnection? {
|
||||
@ -224,12 +255,23 @@ private struct SourceHeaderRow: View {
|
||||
}
|
||||
|
||||
private var showsStatusAccessory: Bool {
|
||||
source.isScanning
|
||||
source.isScanning || installationState != nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusAccessory: some View {
|
||||
if source.isScanning {
|
||||
if let installationState {
|
||||
if installationState.totalCount > 0 {
|
||||
CircularScanProgressView(
|
||||
progress: Double(installationState.completedCount) / Double(installationState.totalCount)
|
||||
)
|
||||
.help(installationState.status)
|
||||
} else {
|
||||
ProgressView()
|
||||
.appActivityIndicatorStyle(.small)
|
||||
.help(installationState.status)
|
||||
}
|
||||
} else if source.isScanning {
|
||||
if let scanProgress = source.scanProgress {
|
||||
CircularScanProgressView(progress: scanProgress)
|
||||
} else {
|
||||
|
||||
@ -771,6 +771,119 @@ struct World_Manager_for_MinecraftTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func packageInstallerPreparesCompleteWorldContents() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let worldURL = workingURL.appendingPathComponent("World", isDirectory: true)
|
||||
let archiveURL = workingURL.appendingPathComponent("World.mcworld", isDirectory: false)
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(
|
||||
at: worldURL.appendingPathComponent("db", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data([1, 2, 3]).write(to: worldURL.appendingPathComponent("level.dat"))
|
||||
try Data([4, 5, 6]).write(to: worldURL.appendingPathComponent("db/chunk.bin"))
|
||||
try makeArchive(from: worldURL, to: archiveURL)
|
||||
|
||||
let plan = try MinecraftPackageInstaller.preparePackage(at: archiveURL)
|
||||
defer { plan.cleanup() }
|
||||
|
||||
#expect(plan.payloads.count == 1)
|
||||
#expect(plan.payloads[0].contentType == .world)
|
||||
#expect(
|
||||
fileManager.fileExists(
|
||||
atPath: plan.payloads[0].preparedDirectoryURL.appendingPathComponent("db/chunk.bin").path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func packageInstallerExpandsNestedAddonPackages() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let behaviorURL = workingURL.appendingPathComponent("Behavior", isDirectory: true)
|
||||
let resourceURL = workingURL.appendingPathComponent("Resource", isDirectory: true)
|
||||
let addonRootURL = workingURL.appendingPathComponent("Addon", isDirectory: true)
|
||||
let behaviorArchiveURL = addonRootURL.appendingPathComponent("Behavior.mcpack")
|
||||
let resourceArchiveURL = addonRootURL.appendingPathComponent("Resource.mcpack")
|
||||
let addonURL = workingURL.appendingPathComponent("Combined.mcaddon")
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: behaviorURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: resourceURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: addonRootURL, withIntermediateDirectories: true)
|
||||
try testPackManifest(name: "Behavior", uuid: "11111111-1111-1111-1111-111111111111", moduleType: "data")
|
||||
.write(to: behaviorURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
||||
try testPackManifest(name: "Resource", uuid: "22222222-2222-2222-2222-222222222222", moduleType: "resources")
|
||||
.write(to: resourceURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
||||
try makeArchive(from: behaviorURL, to: behaviorArchiveURL)
|
||||
try makeArchive(from: resourceURL, to: resourceArchiveURL)
|
||||
try makeArchive(from: addonRootURL, to: addonURL)
|
||||
|
||||
let plan = try MinecraftPackageInstaller.preparePackage(at: addonURL)
|
||||
defer { plan.cleanup() }
|
||||
|
||||
#expect(Set(plan.payloads.map(\.contentType)) == [.behaviorPack, .resourcePack])
|
||||
}
|
||||
|
||||
@Test func localFolderInstallerUsesUniqueAdditiveDestinations() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let sourceWorldURL = workingURL.appendingPathComponent("PreparedWorld", isDirectory: true)
|
||||
let destinationRootURL = workingURL.appendingPathComponent("Destination", isDirectory: true)
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: sourceWorldURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
|
||||
try Data([1]).write(to: sourceWorldURL.appendingPathComponent("level.dat"))
|
||||
|
||||
let plan = try MinecraftPackageInstaller.prepareDirectory(at: sourceWorldURL, contentType: .world)
|
||||
defer { plan.cleanup() }
|
||||
let source = MinecraftSource(folderURL: destinationRootURL, availability: .available)
|
||||
let access = LocalFolderSourceAccess()
|
||||
|
||||
let first = try await access.install(plan.payloads[0], in: source)
|
||||
let second = try await access.install(plan.payloads[0], in: source)
|
||||
|
||||
#expect(first.destinationName != second.destinationName)
|
||||
#expect(
|
||||
fileManager.fileExists(
|
||||
atPath: destinationRootURL
|
||||
.appendingPathComponent("minecraftWorlds/\(first.destinationName)/level.dat")
|
||||
.path
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
fileManager.fileExists(
|
||||
atPath: destinationRootURL
|
||||
.appendingPathComponent("minecraftWorlds/\(second.destinationName)/level.dat")
|
||||
.path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func zipReaderRejectsParentTraversalEntry() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let archiveURL = workingURL.appendingPathComponent("Unsafe.mcworld")
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
|
||||
try makeStoredArchive(entries: [("../escape.txt", Data("escape".utf8))], to: archiveURL)
|
||||
|
||||
do {
|
||||
_ = try ZipArchiveReader(url: archiveURL)
|
||||
Issue.record("Expected the unsafe archive path to be rejected.")
|
||||
} catch let error as ZipArchiveReaderError {
|
||||
switch error {
|
||||
case .unsafeEntryPath(let path):
|
||||
#expect(path == "../escape.txt")
|
||||
default:
|
||||
Issue.record("Expected unsafeEntryPath but received \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@ -1122,6 +1235,24 @@ struct World_Manager_for_MinecraftTests {
|
||||
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
|
||||
}
|
||||
|
||||
@Test func liveScanStatusCountsItemsRemainingDuringSizeCalculation() async throws {
|
||||
var source = MinecraftSource(
|
||||
folderURL: URL(fileURLWithPath: "/tmp/test-source", isDirectory: true)
|
||||
)
|
||||
source.displayName = "Test Source"
|
||||
source.isScanning = true
|
||||
source.indexedItemCount = 22
|
||||
source.previewLoadedCount = 22
|
||||
source.sizeLoadedCount = 12
|
||||
source.scanProgress = 0.88
|
||||
source.scanStatus = "Calculating sizes"
|
||||
|
||||
#expect(
|
||||
SourcePresentation.liveScanStatusTitle(for: source)
|
||||
== "Calculating sizes for 10 of 22 items..."
|
||||
)
|
||||
}
|
||||
|
||||
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
|
||||
let service = ScanNotificationService()
|
||||
|
||||
@ -1251,6 +1382,82 @@ private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throw
|
||||
}
|
||||
}
|
||||
|
||||
private func testPackManifest(name: String, uuid: String, moduleType: String) -> String {
|
||||
"""
|
||||
{
|
||||
"format_version": 2,
|
||||
"header": {
|
||||
"name": "\(name)",
|
||||
"description": "\(name)",
|
||||
"uuid": "\(uuid)",
|
||||
"version": [1, 0, 0]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"type": "\(moduleType)",
|
||||
"uuid": "\(UUID().uuidString)",
|
||||
"version": [1, 0, 0]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func makeStoredArchive(entries: [(String, Data)], to archiveURL: URL) throws {
|
||||
var archive = Data()
|
||||
var centralDirectory = Data()
|
||||
|
||||
for (path, contents) in entries {
|
||||
let pathData = Data(path.utf8)
|
||||
let localHeaderOffset = UInt32(archive.count)
|
||||
|
||||
appendLE(UInt32(0x04034b50), to: &archive)
|
||||
appendLE(UInt16(20), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt32(0), to: &archive)
|
||||
appendLE(UInt32(contents.count), to: &archive)
|
||||
appendLE(UInt32(contents.count), to: &archive)
|
||||
appendLE(UInt16(pathData.count), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
archive.append(pathData)
|
||||
archive.append(contents)
|
||||
|
||||
appendLE(UInt32(0x02014b50), to: ¢ralDirectory)
|
||||
appendLE(UInt16(20), to: ¢ralDirectory)
|
||||
appendLE(UInt16(20), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt32(0), to: ¢ralDirectory)
|
||||
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
||||
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
||||
appendLE(UInt16(pathData.count), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
||||
appendLE(UInt32(0), to: ¢ralDirectory)
|
||||
appendLE(localHeaderOffset, to: ¢ralDirectory)
|
||||
centralDirectory.append(pathData)
|
||||
}
|
||||
|
||||
let centralDirectoryOffset = UInt32(archive.count)
|
||||
archive.append(centralDirectory)
|
||||
appendLE(UInt32(0x06054b50), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
appendLE(UInt16(entries.count), to: &archive)
|
||||
appendLE(UInt16(entries.count), to: &archive)
|
||||
appendLE(UInt32(centralDirectory.count), to: &archive)
|
||||
appendLE(centralDirectoryOffset, to: &archive)
|
||||
appendLE(UInt16(0), to: &archive)
|
||||
try archive.write(to: archiveURL, options: .atomic)
|
||||
}
|
||||
|
||||
private enum ArchiveTestError: LocalizedError {
|
||||
case failedToCreateArchive(String)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
World Manager can browse Minecraft Bedrock content from a trusted iPhone or iPad on macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
||||
World Manager can browse and install Minecraft Bedrock content on a trusted iPhone or iPad from macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
||||
|
||||
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
|
||||
|
||||
@ -87,6 +87,18 @@ During materialization:
|
||||
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
||||
- Temporary materialized folders are treated as disposable.
|
||||
|
||||
During installation:
|
||||
|
||||
- Package contents are fully extracted and validated on the Mac.
|
||||
- The bridge creates an operation directory under `Documents/games/com.mojang/.world-manager-staging`.
|
||||
- Files are uploaded through `AFCFileRefWrite`.
|
||||
- The destination collection is created if needed.
|
||||
- `AFCRenamePath` moves the completed staging directory into the appropriate collection.
|
||||
- A unique destination directory is selected, so an existing world or folder is not overwritten.
|
||||
- Failed staging trees are removed recursively through AFC.
|
||||
|
||||
The write symbols are loaded opportunistically. Their absence prevents installation but does not disable read-only device access.
|
||||
|
||||
## Persistence
|
||||
|
||||
Connected-device sources are persisted in the SQLite source cache with:
|
||||
@ -132,3 +144,6 @@ These commands require a trusted connected device and generally need to run outs
|
||||
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
||||
- Device access requires trust, unlock state, and a vendable app container.
|
||||
- Connected-device export/reveal operations may be slower than local folder operations because they materialize remote content on demand.
|
||||
- Device writes are serialized with scans and exports for the same device.
|
||||
- Multi-item imports can be partially completed if the device disconnects between items. The app reports the completed count and rescans the destination when it reconnects.
|
||||
- Pack replacement and removal are intentionally not implemented in the additive first version.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user