Compare commits

..

13 Commits

48 changed files with 4360 additions and 1783 deletions

3
.gitignore vendored
View File

@ -18,3 +18,6 @@ xcuserdata/
# Swift Package Manager local state
.swiftpm/
# Example data
exampledata/

View File

@ -17,8 +17,6 @@ This thing is called "World Manager," and it happens to pertain to someone else'
- Detect worlds, resource packs, behavior packs, and world templates.
- 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
@ -45,14 +43,6 @@ From an item's detail view, you can export it to a Minecraft package file on you
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
### 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.
@ -107,7 +97,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. Connected-device installation writes through private Apple MobileDevice interfaces and should be treated as best-effort. The initial implementation installs only into new directories and does not replace or remove existing content.
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. The project is currently focused on read-only library access and export, and connected-device discovery, so it shouldn't be doing anything that can break your existing libraries.
## Trademarks

View File

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

View File

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

View File

@ -152,7 +152,7 @@ nonisolated struct CollectionSnapshot: Identifiable, Hashable, Sendable, Codable
let childDirectoryCount: Int
let fingerprint: String
var id: String { folderName }
var id: String { "\(folderName)::\(fingerprint)" }
}
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {

View File

@ -3,6 +3,47 @@
import Foundation
typealias PlatformProviderID = String
nonisolated enum MinecraftEdition: String, CaseIterable, Hashable, Sendable, Codable {
case bedrock
case java
}
nonisolated enum MinecraftContentKind: String, CaseIterable, Hashable, Sendable, Codable {
case world
case behaviorPack
case resourcePack
case dataPack
case skinPack
case worldTemplate
case shaderPack
case mod
}
nonisolated enum JavaContentType: String, CaseIterable, Hashable, Sendable, Codable {
case world = "Java World"
case resourcePack = "Java Resource Pack"
case dataPack = "Java Data Pack"
case shaderPack = "Java Shader Pack"
case mod = "Java Mod"
nonisolated var kind: MinecraftContentKind {
switch self {
case .world:
return .world
case .resourcePack:
return .resourcePack
case .dataPack:
return .dataPack
case .shaderPack:
return .shaderPack
case .mod:
return .mod
}
}
}
nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable {
case world = "World"
case behaviorPack = "Behavior Pack"
@ -25,6 +66,21 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable,
}
}
nonisolated var kind: MinecraftContentKind {
switch self {
case .world:
return .world
case .behaviorPack:
return .behaviorPack
case .resourcePack:
return .resourcePack
case .skinPack:
return .skinPack
case .worldTemplate:
return .worldTemplate
}
}
nonisolated var archiveExtension: String {
switch self {
case .world:
@ -52,6 +108,71 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable,
}
}
nonisolated enum MinecraftPlatformContentType: Hashable, Sendable, Codable {
case bedrock(MinecraftContentType)
case java(JavaContentType)
nonisolated var edition: MinecraftEdition {
switch self {
case .bedrock:
return .bedrock
case .java:
return .java
}
}
nonisolated var kind: MinecraftContentKind {
switch self {
case .bedrock(let contentType):
return contentType.kind
case .java(let contentType):
return contentType.kind
}
}
nonisolated var displayName: String {
switch self {
case .bedrock(let contentType):
return contentType.rawValue
case .java(let contentType):
return contentType.rawValue
}
}
}
nonisolated struct ContentItemCapabilities: Hashable, Sendable, Codable {
var canRevealNativeContent: Bool
var canExportPortablePackage: Bool
var canShare: Bool
var portablePackageExtension: String?
nonisolated static func bedrock(contentType: MinecraftContentType) -> ContentItemCapabilities {
ContentItemCapabilities(
canRevealNativeContent: true,
canExportPortablePackage: true,
canShare: true,
portablePackageExtension: contentType.archiveExtension
)
}
nonisolated static func java(contentType: JavaContentType) -> ContentItemCapabilities {
let extensionName: String?
switch contentType {
case .world, .resourcePack, .dataPack, .shaderPack:
extensionName = "zip"
case .mod:
extensionName = "jar"
}
return ContentItemCapabilities(
canRevealNativeContent: true,
canExportPortablePackage: true,
canShare: true,
portablePackageExtension: extensionName
)
}
}
nonisolated enum PackSource: String, Hashable, Sendable, Codable {
case referencedByWorld
case embeddedInWorld
@ -113,11 +234,119 @@ nonisolated struct PackMetadataDetails: Hashable, Sendable, Codable {
var minimumEngineVersion: String?
}
nonisolated enum PlatformContentMetadata: Hashable, Sendable, Codable {
case bedrock(BedrockContentMetadata)
case java(JavaContentMetadata)
case none
}
nonisolated struct BedrockContentMetadata: Hashable, Sendable, Codable {
var world: WorldMetadata?
var packUUID: String?
var packVersion: String?
var packDetails: PackMetadataDetails?
var packReferences: [ContentPackReference]
nonisolated init(
world: WorldMetadata? = nil,
packUUID: String? = nil,
packVersion: String? = nil,
packDetails: PackMetadataDetails? = nil,
packReferences: [ContentPackReference] = []
) {
self.world = world
self.packUUID = packUUID?.lowercased()
self.packVersion = packVersion
self.packDetails = packDetails
self.packReferences = packReferences
}
}
nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable {
var world: JavaWorldMetadata?
var pack: JavaPackMetadata?
var mod: JavaModMetadata?
var dataPacks: [JavaPackReference]
nonisolated init(
world: JavaWorldMetadata? = nil,
pack: JavaPackMetadata? = nil,
mod: JavaModMetadata? = nil,
dataPacks: [JavaPackReference] = []
) {
self.world = world
self.pack = pack
self.mod = mod
self.dataPacks = dataPacks
}
}
nonisolated struct JavaWorldMetadata: Hashable, Sendable, Codable {
var dataVersion: String?
var gameMode: String?
var difficulty: String?
var seed: String?
var lastPlayedDate: Date?
}
nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable {
var packFormat: Int?
var supportedFormats: String?
var description: String?
nonisolated init(
packFormat: Int? = nil,
supportedFormats: String? = nil,
description: String? = nil
) {
self.packFormat = packFormat
self.supportedFormats = supportedFormats
self.description = description
}
}
nonisolated struct JavaModMetadata: Hashable, Sendable, Codable {
var modID: String?
var version: String?
var description: String?
var authors: [String]
var license: String?
var environment: String?
var minecraftVersionRequirement: String?
nonisolated init(
modID: String? = nil,
version: String? = nil,
description: String? = nil,
authors: [String] = [],
license: String? = nil,
environment: String? = nil,
minecraftVersionRequirement: String? = nil
) {
self.modID = modID
self.version = version
self.description = description
self.authors = authors
self.license = license
self.environment = environment
self.minecraftVersionRequirement = minecraftVersionRequirement
}
}
nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable {
let id: String
var name: String
var pathHint: String?
}
nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
let id: URL
let folderURL: URL
let folderName: String
let contentType: MinecraftContentType
let sourceEdition: MinecraftEdition
let contentKind: MinecraftContentKind
let platformType: MinecraftPlatformContentType
let collectionRootURL: URL
var displayName: String
var iconURL: URL?
@ -125,11 +354,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
var lastPlayedDate: Date?
var modifiedDate: Date?
var sizeBytes: Int64?
var packUUID: String?
var packVersion: String?
var packMetadataDetails: PackMetadataDetails?
var packReferences: [ContentPackReference]
var worldMetadata: WorldMetadata?
var capabilities: ContentItemCapabilities
var platformMetadata: PlatformContentMetadata
var packUUID: String? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var packVersion: String? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var packMetadataDetails: PackMetadataDetails? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var packReferences: [ContentPackReference] {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var worldMetadata: WorldMetadata? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var metadataLoaded: Bool
var previewLoaded: Bool
var sizeLoaded: Bool
@ -138,6 +379,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
folderURL: URL,
folderName: String,
contentType: MinecraftContentType,
sourceEdition: MinecraftEdition? = nil,
contentKind: MinecraftContentKind? = nil,
platformType: MinecraftPlatformContentType? = nil,
collectionRootURL: URL,
displayName: String? = nil,
iconURL: URL? = nil,
@ -145,6 +389,8 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
lastPlayedDate: Date? = nil,
modifiedDate: Date? = nil,
sizeBytes: Int64? = nil,
capabilities: ContentItemCapabilities? = nil,
platformMetadata: PlatformContentMetadata? = nil,
packUUID: String? = nil,
packVersion: String? = nil,
packMetadataDetails: PackMetadataDetails? = nil,
@ -158,6 +404,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.folderURL = folderURL
self.folderName = folderName
self.contentType = contentType
self.sourceEdition = sourceEdition ?? .bedrock
self.contentKind = contentKind ?? contentType.kind
self.platformType = platformType ?? .bedrock(contentType)
self.collectionRootURL = collectionRootURL
self.displayName = displayName ?? folderName
self.iconURL = iconURL
@ -165,6 +414,16 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.lastPlayedDate = lastPlayedDate
self.modifiedDate = modifiedDate
self.sizeBytes = sizeBytes
self.capabilities = capabilities ?? .bedrock(contentType: contentType)
self.platformMetadata = platformMetadata ?? .bedrock(
BedrockContentMetadata(
world: worldMetadata,
packUUID: packUUID,
packVersion: packVersion,
packDetails: packMetadataDetails,
packReferences: packReferences
)
)
self.packUUID = packUUID?.lowercased()
self.packVersion = packVersion
self.packMetadataDetails = packMetadataDetails
@ -175,6 +434,22 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.sizeLoaded = sizeLoaded
}
nonisolated mutating private func syncBedrockMetadataFromCompatibilityFields() {
guard sourceEdition == .bedrock else {
return
}
platformMetadata = .bedrock(
BedrockContentMetadata(
world: worldMetadata,
packUUID: packUUID,
packVersion: packVersion,
packDetails: packMetadataDetails,
packReferences: packReferences
)
)
}
nonisolated var folderID: String {
folderName
}
@ -201,6 +476,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
values.append(packMetadataDetails?.minimumEngineVersion ?? "")
values.append(packReferences.map(\.name).joined(separator: " "))
values.append(packReferences.compactMap(\.uuid).joined(separator: " "))
if case .java(let metadata) = platformMetadata {
values.append(metadata.world?.dataVersion ?? "")
values.append(metadata.world?.gameMode ?? "")
values.append(metadata.world?.difficulty ?? "")
values.append(metadata.world?.seed ?? "")
values.append(metadata.pack?.description ?? "")
values.append(metadata.pack?.packFormat.map(String.init) ?? "")
values.append(metadata.pack?.supportedFormats ?? "")
values.append(metadata.mod?.modID ?? "")
values.append(metadata.mod?.version ?? "")
values.append(metadata.mod?.description ?? "")
values.append(metadata.mod?.authors.joined(separator: " ") ?? "")
values.append(metadata.mod?.license ?? "")
values.append(metadata.mod?.environment ?? "")
values.append(metadata.mod?.minecraftVersionRequirement ?? "")
values.append(metadata.dataPacks.map(\.name).joined(separator: " "))
}
return values
.filter { !$0.isEmpty }

View File

@ -6,14 +6,18 @@ import Foundation
nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
let id: URL
let folderURL: URL
var edition: MinecraftEdition
var providerID: PlatformProviderID
var origin: MinecraftSourceOrigin
var accessDescriptor: SourceAccessDescriptor
var accessStatus: SourceAccessStatus
var availability: SourceAvailability
var capabilities: SourceCapabilities
var bookmarkData: Data?
var displayName: String
var displayItems: [MinecraftContentItem]
var displayItemCountsByType: [MinecraftContentType: Int]
var displayItemCountsByKind: [MinecraftContentKind: Int]
var rawItems: [MinecraftContentItem]
var logicalPacks: [LogicalPack]
var logicalWorlds: [LogicalWorld]
@ -47,18 +51,22 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData)
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
self.folderURL = normalizedFolderURL
self.edition = resolvedOrigin.defaultEdition
self.providerID = resolvedOrigin.defaultAccessorIdentifier
self.origin = resolvedOrigin
self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor(
accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier,
kind: resolvedOrigin.kind,
refreshStrategy: resolvedOrigin.defaultRefreshStrategy
)
self.accessStatus = resolvedOrigin.defaultAccessStatus(displayName: normalizedFolderURL.lastPathComponent)
self.availability = availability
self.capabilities = resolvedOrigin.defaultCapabilities
self.bookmarkData = bookmarkData
self.displayName = normalizedFolderURL.lastPathComponent
self.displayItems = []
self.displayItemCountsByType = [:]
self.displayItemCountsByKind = [:]
self.rawItems = []
self.logicalPacks = []
self.logicalWorlds = []
@ -107,6 +115,8 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
}
switch selection {
case .sourceCandidate, .connectedDevice:
return []
case .source(let sourceID), .allContent(let sourceID):
guard sourceID == id else {
return []
@ -117,6 +127,11 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
return []
}
return items(for: contentType)
case .contentKind(let sourceID, let contentKind):
guard sourceID == id else {
return []
}
return displayItems.filter { $0.contentKind == contentKind }
}
}

View File

@ -7,19 +7,16 @@ 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,
canInstallItems: true
canExportPortablePackages: true
)
static let connectedDevice = SourceCapabilities(
canScan: true,
canMaterializeItems: true,
canExportPortablePackages: true,
canInstallItems: true
canExportPortablePackages: true
)
}

View File

@ -45,20 +45,32 @@ nonisolated enum DeviceContainerAccessMode: String, Hashable, Sendable, Codable
nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
case localFolder(bookmarkData: Data?)
case javaLocalFolder(bookmarkData: Data?)
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
switch self {
case .localFolder:
return LocalFolderSourceAccess().accessorIdentifier
case .javaLocalFolder:
return JavaLocalFolderSourceAccess().accessorIdentifier
case .connectedDevice:
return AppleMobileDeviceSourceAccess().accessorIdentifier
}
}
nonisolated var defaultEdition: MinecraftEdition {
switch self {
case .localFolder, .connectedDevice:
return .bedrock
case .javaLocalFolder:
return .java
}
}
nonisolated var kind: MinecraftSourceKind {
switch self {
case .localFolder:
case .localFolder, .javaLocalFolder:
return .localFolder
case .connectedDevice:
return .connectedDevice
@ -67,7 +79,7 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
switch self {
case .localFolder:
case .localFolder, .javaLocalFolder:
return .eagerFullScan
case .connectedDevice:
return .staged
@ -76,12 +88,35 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
nonisolated var defaultCapabilities: SourceCapabilities {
switch self {
case .localFolder:
case .localFolder, .javaLocalFolder:
return .localFolder
case .connectedDevice:
return .connectedDevice
}
}
nonisolated func defaultAccessStatus(displayName: String) -> SourceAccessStatus {
switch self {
case .localFolder(let bookmarkData), .javaLocalFolder(let bookmarkData):
return SourceAccessStatus(
availability: .unknown,
mode: bookmarkData == nil ? .localFileSystem : .securityScopedLocalFolder,
displayName: displayName,
iconSystemName: "folder",
statusText: nil,
warningText: nil
)
case .connectedDevice(let device, _):
return SourceAccessStatus(
availability: .unknown,
mode: device.connection == .usb ? .usbDevice : .networkDevice,
displayName: displayName,
iconSystemName: "iphone.gen3",
statusText: nil,
warningText: nil
)
}
}
}
nonisolated enum MinecraftSourceKind: String, Hashable, Sendable, Codable {

View File

@ -24,6 +24,114 @@ nonisolated struct SourceAccessDescriptor: Hashable, Sendable, Codable {
var refreshStrategy: SourceRefreshStrategy
}
nonisolated enum SourceAccessMode: String, Hashable, Sendable, Codable {
case localFileSystem
case securityScopedLocalFolder
case usbDevice
case networkDevice
case archive
case unknown
}
nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable {
var availability: SourceAvailability
var mode: SourceAccessMode
var displayName: String
var iconSystemName: String
var statusText: String?
var warningText: String?
}
nonisolated enum SourceProbeConfidence: Int, Comparable, Hashable, Sendable, Codable {
case none = 0
case weak = 25
case medium = 50
case strong = 75
case exact = 100
static func < (lhs: SourceProbeConfidence, rhs: SourceProbeConfidence) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
nonisolated struct SourceProbeResult: Hashable, Sendable {
let providerID: PlatformProviderID
let edition: MinecraftEdition
let confidence: SourceProbeConfidence
let sourceRootURL: URL
let displayName: String
let detectedKinds: Set<MinecraftContentKind>
let warnings: [String]
}
nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable {
var providerID: PlatformProviderID
var edition: MinecraftEdition
var sourceRootURL: URL
var displayName: String
var confidence: SourceProbeConfidence
var reason: String
var detectedKinds: Set<MinecraftContentKind>
var id: String {
[
providerID,
sourceIdentityKey(for: sourceRootURL)
].joined(separator: "::")
}
}
nonisolated func sourceIdentityKey(for url: URL) -> String {
if url.isFileURL {
return url.standardizedFileURL.resolvingSymlinksInPath().path.lowercased()
}
return url.standardized.absoluteString.lowercased()
}
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending
case running
case succeeded
case failed
case skipped
case cancelled
}
nonisolated enum WorkProgress: Hashable, Sendable, Codable {
case indeterminate
case fraction(Double)
case count(completed: Int, total: Int?)
}
nonisolated struct WorkStage: Identifiable, Hashable, Sendable, Codable {
let id: String
var title: String
var detail: String?
var state: WorkStageState
var progress: WorkProgress
}
nonisolated struct ProviderWarning: Identifiable, Hashable, Sendable, Codable {
let id: String
var message: String
var detail: String?
}
nonisolated enum ProviderEvent: Sendable {
case accessStatusChanged(SourceAccessStatus)
case stageUpdated(WorkStage)
case discovered(MinecraftContentItem)
case inspected(MinecraftContentItem)
case warning(ProviderWarning)
}
nonisolated enum SourceCandidateEvent: Sendable {
case stageUpdated(WorkStage)
case candidate(SourceCandidate)
case warning(ProviderWarning)
}
nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
let id: URL
var displayName: String

View File

@ -16,7 +16,7 @@ struct ContentItemActionService: Sendable {
}
nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
UTType(filenameExtension: item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension) ?? .data
}
nonisolated func persistExternalRepresentation(

View File

@ -37,7 +37,11 @@ enum ContentPackageExporter {
try fileManager.removeItem(at: archiveURL)
}
try await createArchive(for: item, source: source, at: archiveURL)
if isPortableFileItem(item) {
try copyPortableFileItem(item, to: archiveURL, fileManager: fileManager)
} else {
try await createArchive(for: item, source: source, at: archiveURL)
}
return archiveURL
}
@ -46,7 +50,7 @@ enum ContentPackageExporter {
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
"\(suggestedBaseFilename(for: item)).\(archiveExtension(for: item))"
}
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
@ -209,7 +213,7 @@ enum ContentPackageExporter {
return requestDirectoryURL
.appendingPathComponent(suggestedBaseFilename(for: item))
.appendingPathExtension(item.contentType.archiveExtension)
.appendingPathExtension(archiveExtension(for: item))
}
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String {
@ -271,7 +275,7 @@ enum ContentPackageExporter {
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension
let requiredExtension = archiveExtension(for: item)
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL
@ -280,6 +284,32 @@ enum ContentPackageExporter {
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
}
nonisolated private static func archiveExtension(for item: MinecraftContentItem) -> String {
item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension
}
nonisolated private static func isPortableFileItem(_ item: MinecraftContentItem) -> Bool {
guard item.sourceEdition == .java else {
return false
}
guard let expectedExtension = item.capabilities.portablePackageExtension else {
return false
}
let values = try? item.folderURL.resourceValues(forKeys: [.isRegularFileKey])
return values?.isRegularFile == true
&& item.folderURL.pathExtension.localizedCaseInsensitiveCompare(expectedExtension) == .orderedSame
}
nonisolated private static func copyPortableFileItem(
_ item: MinecraftContentItem,
to destinationURL: URL,
fileManager: FileManager
) throws {
try fileManager.copyItem(at: item.folderURL, to: destinationURL)
}
nonisolated private static func uniqueArchiveURL(
in directoryURL: URL,
baseName: String,

View File

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

View File

@ -25,14 +25,32 @@ struct ContentItemFileFacts: Sendable {
self.approximateAgeText = nil
}
switch item.contentType {
case .world:
let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path)
? "LevelDB world storage"
: "Flat-file world storage"
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
self.storageFormatLabel = "Manifest-based package"
switch item.sourceEdition {
case .bedrock:
switch item.contentType {
case .world:
let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path)
? "LevelDB world storage"
: "Flat-file world storage"
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
self.storageFormatLabel = "Manifest-based package"
}
case .java:
switch item.contentKind {
case .world:
self.storageFormatLabel = "Anvil world storage"
case .mod:
self.storageFormatLabel = "Java mod archive"
case .shaderPack:
self.storageFormatLabel = "Shader pack archive"
case .resourcePack:
self.storageFormatLabel = "Resource pack archive"
case .dataPack:
self.storageFormatLabel = "Data pack archive"
case .behaviorPack, .skinPack, .worldTemplate:
self.storageFormatLabel = "Java content"
}
}
}
}

View File

@ -3,7 +3,9 @@
import Foundation
enum WorldScanner {
typealias WorldScanner = BedrockContentScanner
enum BedrockContentScanner {
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
let fileManager = FileManager.default
var sizedItem = item
@ -20,6 +22,49 @@ enum WorldScanner {
await packReferenceIndexStore.reset(for: sourceRootURL)
}
nonisolated static func probeLocalFolder(_ url: URL, providerID: PlatformProviderID) -> SourceProbeResult? {
let fileManager = FileManager.default
let normalizedURL = url.standardizedFileURL
var detectedKinds = Set<MinecraftContentKind>()
var score = 0
let collectionKinds: [(String, MinecraftContentKind)] = [
("minecraftWorlds", .world),
("behavior_packs", .behaviorPack),
("resource_packs", .resourcePack),
("skin_packs", .skinPack),
("world_templates", .worldTemplate)
]
for (folderName, kind) in collectionKinds {
if fileManager.fileExists(atPath: normalizedURL.appendingPathComponent(folderName, isDirectory: true).path) {
detectedKinds.insert(kind)
score += 25
}
}
if fileManager.fileExists(atPath: normalizedURL.appendingPathComponent("db", isDirectory: true).path)
|| fileManager.fileExists(atPath: normalizedURL.appendingPathComponent("levelname.txt").path) {
detectedKinds.insert(.world)
score += 35
}
guard score > 0 else {
return nil
}
let confidence: SourceProbeConfidence = score >= 50 ? .strong : .medium
return SourceProbeResult(
providerID: providerID,
edition: .bedrock,
confidence: confidence,
sourceRootURL: normalizedURL,
displayName: normalizedURL.lastPathComponent,
detectedKinds: detectedKinds,
warnings: []
)
}
nonisolated static func discoverItems(
in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
@ -162,7 +207,7 @@ enum WorldScanner {
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
: nil
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
enrichedItem.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version
@ -322,11 +367,11 @@ enum WorldScanner {
return worldMetadata?.lastPlayedDate
}
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
nonisolated fileprivate static func modifiedDate(for directoryURL: URL) -> Date? {
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
}
nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
nonisolated fileprivate static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
@ -580,4 +625,596 @@ private actor PackReferenceIndexStore {
}
}
enum JavaContentScanner {
nonisolated static func probeLocalFolder(_ url: URL, providerID: PlatformProviderID) -> SourceProbeResult? {
let fileManager = FileManager.default
let candidates = localFolderProbeCandidates(for: url.standardizedFileURL, fileManager: fileManager)
let scoredCandidates = candidates.compactMap { candidate -> (url: URL, score: Int, kinds: Set<MinecraftContentKind>)? in
let score = javaProbeScore(for: candidate, fileManager: fileManager)
guard score.value > 0 else {
return nil
}
return (candidate, score.value, score.kinds)
}
guard let best = scoredCandidates.max(by: { lhs, rhs in
if lhs.score != rhs.score {
return lhs.score < rhs.score
}
return lhs.url.path.count > rhs.url.path.count
}) else {
return nil
}
let confidence: SourceProbeConfidence
if best.score >= 70 {
confidence = .exact
} else if best.score >= 45 {
confidence = .strong
} else {
confidence = .medium
}
let warnings = best.url.standardizedFileURL == url.standardizedFileURL ? [] : [
"Using nested Java instance folder: \(best.url.lastPathComponent)"
]
return SourceProbeResult(
providerID: providerID,
edition: .java,
confidence: confidence,
sourceRootURL: best.url.standardizedFileURL,
displayName: best.url.lastPathComponent,
detectedKinds: best.kinds,
warnings: warnings
)
}
nonisolated static func discoverSourceCandidates(
providerID: PlatformProviderID,
searchRoots: [URL]? = nil,
fileManager: FileManager = .default
) -> [SourceCandidate] {
let roots = uniqueStandardizedURLs(searchRoots ?? defaultCandidateSearchRoots(fileManager: fileManager))
.map(\.standardizedFileURL)
.filter { fileManager.fileExists(atPath: $0.path) }
var candidatesByID: [String: SourceCandidate] = [:]
for root in roots {
let candidateFolders = boundedCandidateFolders(from: root, maxDepth: 4, maxFolderCount: 600, fileManager: fileManager)
var candidatesForRoot: [SourceCandidate] = []
for folderURL in candidateFolders {
guard let probe = probeLocalFolder(folderURL, providerID: providerID) else {
continue
}
let candidate = SourceCandidate(
providerID: probe.providerID,
edition: probe.edition,
sourceRootURL: probe.sourceRootURL,
displayName: probe.displayName,
confidence: probe.confidence,
reason: "Found Java markers near \(root.lastPathComponent)",
detectedKinds: probe.detectedKinds
)
candidatesForRoot.append(candidate)
}
for candidate in collapsedCandidates(
candidatesForRoot,
under: root,
providerID: providerID
) {
if let existingCandidate = candidatesByID[candidate.id],
existingCandidate.confidence >= candidate.confidence {
continue
}
candidatesByID[candidate.id] = candidate
}
}
return candidatesByID.values.sorted {
if $0.confidence != $1.confidence {
return $0.confidence > $1.confidence
}
return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
}
}
nonisolated static func discoverItems(
in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
) throws -> [MinecraftContentItem] {
let fileManager = FileManager.default
var discoveredItems: [MinecraftContentItem] = []
for scanRootURL in contentScanRoots(for: searchRootURL, fileManager: fileManager) {
let savesRootURL = existingDirectory(
named: "saves",
in: scanRootURL,
fileManager: fileManager
) ?? scanRootURL
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
discoveredItems.append(contentsOf: worldItems)
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager) {
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
discoveredItems.append(contentsOf: resourcePackItems)
}
if let dataPacksURL = existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: dataPacksURL,
contentKind: .dataPack,
platformType: .dataPack,
packageExtension: "zip",
fileManager: fileManager
))
}
if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: shaderPacksURL,
contentKind: .shaderPack,
platformType: .shaderPack,
packageExtension: "zip",
fileManager: fileManager
))
}
if let modsURL = existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: modsURL,
contentKind: .mod,
platformType: .mod,
packageExtension: "jar",
fileManager: fileManager
))
}
}
discoveredItems.sort(by: WorldScanner.sortItems)
discoveredItems.forEach(onDiscovered)
return discoveredItems
}
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
var enrichedItem = item
let metadata = JavaContentMetadataReader.metadata(for: item)
enrichedItem.displayName = metadata?.displayName ?? displayName(for: item)
enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata)
if metadata?.pack != nil || metadata?.mod != nil {
enrichedItem.platformMetadata = .java(JavaContentMetadata(
pack: metadata?.pack,
mod: metadata?.mod
))
}
enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
enrichedItem.sizeLoaded = false
return enrichedItem
}
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
var sizedItem = item
sizedItem.sizeBytes = contentSize(at: item.folderURL, fileManager: .default)
sizedItem.sizeLoaded = true
return sizedItem
}
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
let fileManager = FileManager.default
var snapshots: [CollectionSnapshot] = []
for scanRootURL in contentScanRoots(for: sourceRootURL, fileManager: fileManager) {
let candidateRoots = [
existingDirectory(named: "saves", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager)
]
for collectionURL in candidateRoots {
guard let collectionURL else {
continue
}
if let snapshot = collectionSnapshot(
for: collectionURL,
sourceRootURL: sourceRootURL,
fileManager: fileManager
) {
snapshots.append(snapshot)
}
}
}
return snapshots
}
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
let worldDirectories = try WorldScanner.immediateChildDirectories(of: savesRootURL, fileManager: fileManager)
return worldDirectories.compactMap { worldURL in
guard fileManager.fileExists(atPath: worldURL.appendingPathComponent("level.dat").path) else {
return nil
}
return MinecraftContentItem(
folderURL: worldURL,
folderName: worldURL.lastPathComponent,
contentType: .world,
sourceEdition: .java,
contentKind: .world,
platformType: .java(.world),
collectionRootURL: savesRootURL,
capabilities: .java(contentType: .world),
platformMetadata: .java(JavaContentMetadata())
)
}
}
nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
try discoverJavaPackages(
in: resourcePacksURL,
contentKind: .resourcePack,
platformType: .resourcePack,
packageExtension: "zip",
fileManager: fileManager,
folderMarker: "pack.mcmeta"
)
}
nonisolated private static func discoverJavaPackages(
in collectionURL: URL,
contentKind: MinecraftContentKind,
platformType: JavaContentType,
packageExtension: String,
fileManager: FileManager,
folderMarker: String? = nil
) throws -> [MinecraftContentItem] {
let children = try fileManager.contentsOfDirectory(
at: collectionURL,
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
)
return children.compactMap { childURL in
let values = try? childURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
let isDirectory = values?.isDirectory == true
let isRegularFile = values?.isRegularFile == true
if isDirectory {
if let folderMarker,
!fileManager.fileExists(atPath: childURL.appendingPathComponent(folderMarker).path) {
return nil
}
} else if isRegularFile {
guard childURL.pathExtension.localizedCaseInsensitiveCompare(packageExtension) == .orderedSame else {
return nil
}
} else {
return nil
}
return javaContentItem(
url: childURL,
contentKind: contentKind,
platformType: platformType,
collectionRootURL: collectionURL
)
}
}
nonisolated private static func existingDirectory(named name: String, in rootURL: URL, fileManager: FileManager) -> URL? {
let directoryURL = rootURL.appendingPathComponent(name, isDirectory: true)
guard (try? directoryURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
return nil
}
return directoryURL
}
nonisolated private static func javaContentItem(
url: URL,
contentKind: MinecraftContentKind,
platformType: JavaContentType,
collectionRootURL: URL
) -> MinecraftContentItem {
MinecraftContentItem(
folderURL: url,
folderName: url.lastPathComponent,
contentType: contentKind == .world ? .world : .resourcePack,
sourceEdition: .java,
contentKind: contentKind,
platformType: .java(platformType),
collectionRootURL: collectionRootURL,
displayName: url.deletingPathExtension().lastPathComponent,
capabilities: .java(contentType: platformType),
platformMetadata: .java(JavaContentMetadata())
)
}
nonisolated private static func contentSize(at url: URL, fileManager: FileManager) -> Int64? {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
if values?.isDirectory == true {
return WorldScanner.folderSize(at: url, fileManager: fileManager)
}
return values?.fileSize.map(Int64.init)
}
nonisolated private static func localFolderProbeCandidates(for url: URL, fileManager: FileManager) -> [URL] {
var candidates = [url]
let children = (try? fileManager.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
)) ?? []
candidates.append(contentsOf: children.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
})
return candidates
}
nonisolated private static func collapsedCandidates(
_ candidates: [SourceCandidate],
under root: URL,
providerID: PlatformProviderID
) -> [SourceCandidate] {
let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.sourceRootURL) }).compactMap { _, groupedCandidates in
groupedCandidates.max { lhs, rhs in
lhs.confidence < rhs.confidence
}
}
guard uniqueCandidates.count > 1 else {
return uniqueCandidates
}
let detectedKinds = uniqueCandidates.reduce(into: Set<MinecraftContentKind>()) { result, candidate in
result.formUnion(candidate.detectedKinds)
}
let confidence = uniqueCandidates.map(\.confidence).max() ?? .medium
let standardizedRoot = root.standardizedFileURL
return [
SourceCandidate(
providerID: providerID,
edition: .java,
sourceRootURL: standardizedRoot,
displayName: standardizedRoot.lastPathComponent,
confidence: confidence,
reason: "Found multiple Java sources under \(standardizedRoot.lastPathComponent)",
detectedKinds: detectedKinds
)
]
}
nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set<MinecraftContentKind>) {
var score = 0
var kinds = Set<MinecraftContentKind>()
if existingDirectory(named: "saves", in: url, fileManager: fileManager) != nil {
kinds.insert(.world)
score += 25
}
if existingDirectory(named: "resourcepacks", in: url, fileManager: fileManager) != nil {
kinds.insert(.resourcePack)
score += 20
}
if existingDirectory(named: "datapacks", in: url, fileManager: fileManager) != nil {
kinds.insert(.dataPack)
score += 15
}
if existingDirectory(named: "shaderpacks", in: url, fileManager: fileManager) != nil {
kinds.insert(.shaderPack)
score += 15
}
if existingDirectory(named: "mods", in: url, fileManager: fileManager) != nil {
kinds.insert(.mod)
score += 20
}
if fileManager.fileExists(atPath: url.appendingPathComponent("options.txt").path)
|| fileManager.fileExists(atPath: url.appendingPathComponent("launcher_profiles.json").path)
|| fileManager.fileExists(atPath: url.appendingPathComponent(".curseclient").path) {
score += 15
}
if fileManager.fileExists(atPath: url.appendingPathComponent("region", isDirectory: true).path)
&& fileManager.fileExists(atPath: url.appendingPathComponent("level.dat").path) {
kinds.insert(.world)
score += 35
}
return (score, kinds)
}
nonisolated private static func defaultCandidateSearchRoots(fileManager: FileManager) -> [URL] {
let homeURL = fileManager.homeDirectoryForCurrentUser
let applicationSupportURL = homeURL
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Application Support", isDirectory: true)
let documentsURL = homeURL.appendingPathComponent("Documents", isDirectory: true)
return [
applicationSupportURL.appendingPathComponent("minecraft", isDirectory: true),
documentsURL.appendingPathComponent("curseforge/minecraft", isDirectory: true),
documentsURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true),
applicationSupportURL.appendingPathComponent("PrismLauncher/instances", isDirectory: true),
applicationSupportURL.appendingPathComponent("MultiMC/instances", isDirectory: true),
applicationSupportURL.appendingPathComponent("PolyMC/instances", isDirectory: true),
applicationSupportURL.appendingPathComponent("com.modrinth.theseus/profiles", isDirectory: true),
applicationSupportURL.appendingPathComponent("ATLauncher/instances", isDirectory: true),
applicationSupportURL.appendingPathComponent("gdlauncher_next/instances", isDirectory: true),
applicationSupportURL.appendingPathComponent("GDLauncher_next/instances", isDirectory: true)
]
}
nonisolated private static func boundedCandidateFolders(
from rootURL: URL,
maxDepth: Int,
maxFolderCount: Int,
fileManager: FileManager
) -> [URL] {
var folders: [URL] = []
var queue: [(url: URL, depth: Int)] = [(rootURL, 0)]
var seen = Set<String>()
while !queue.isEmpty && folders.count < maxFolderCount {
let current = queue.removeFirst()
let normalizedURL = current.url.standardizedFileURL
guard seen.insert(sourceIdentityKey(for: normalizedURL)).inserted else {
continue
}
folders.append(normalizedURL)
guard current.depth < maxDepth else {
continue
}
let children = (try? fileManager.contentsOfDirectory(
at: normalizedURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: []
)) ?? []
let childDirectories = children
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }
.sorted { lhs, rhs in
lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending
}
queue.append(contentsOf: childDirectories.map { ($0, current.depth + 1) })
}
return folders
}
nonisolated private static func contentScanRoots(for sourceRootURL: URL, fileManager: FileManager) -> [URL] {
let standardizedRoot = sourceRootURL.standardizedFileURL
if javaProbeScore(for: standardizedRoot, fileManager: fileManager).value > 0 {
return [standardizedRoot]
}
let discoveredRoots = boundedCandidateFolders(
from: standardizedRoot,
maxDepth: 4,
maxFolderCount: 600,
fileManager: fileManager
).filter { candidateURL in
candidateURL != standardizedRoot
&& javaProbeScore(for: candidateURL, fileManager: fileManager).value > 0
}
return uniqueStandardizedURLs(discoveredRoots).sorted {
$0.path.localizedStandardCompare($1.path) == .orderedAscending
}
}
nonisolated private static func uniqueStandardizedURLs(_ urls: [URL]) -> [URL] {
var seen = Set<String>()
var result: [URL] = []
result.reserveCapacity(urls.count)
for url in urls {
let standardizedURL = url.standardizedFileURL
guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else {
continue
}
result.append(standardizedURL)
}
return result
}
nonisolated private static func collectionSnapshot(
for collectionURL: URL,
sourceRootURL: URL,
fileManager: FileManager
) -> CollectionSnapshot? {
guard fileManager.fileExists(atPath: collectionURL.path) else {
return nil
}
let children = (try? fileManager.contentsOfDirectory(
at: collectionURL,
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]
)) ?? []
let childSnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?, size: Int?)? in
let values = try? childURL.resourceValues(forKeys: [
.isDirectoryKey,
.isRegularFileKey,
.contentModificationDateKey,
.fileSizeKey
])
guard values?.isDirectory == true || values?.isRegularFile == true else {
return nil
}
return (childURL.lastPathComponent, values?.contentModificationDate, values?.fileSize)
}.sorted {
$0.name.localizedStandardCompare($1.name) == .orderedAscending
}
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
let childFingerprint = childSnapshots.map { child in
[
child.name,
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
child.size.map(String.init) ?? "nil"
].joined(separator: "@")
}.joined(separator: "|")
let folderName = relativePath(from: sourceRootURL.standardizedFileURL, to: collectionURL.standardizedFileURL)
?? collectionURL.lastPathComponent
return CollectionSnapshot(
folderName: folderName,
modifiedDate: modifiedDate,
childDirectoryCount: childSnapshots.count,
fingerprint: [
folderName,
String(childSnapshots.count),
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
childFingerprint
].joined(separator: "::")
)
}
nonisolated private static func relativePath(from rootURL: URL, to childURL: URL) -> String? {
let rootPath = rootURL.standardizedFileURL.path
let childPath = childURL.standardizedFileURL.path
guard childPath.hasPrefix(rootPath + "/") else {
return nil
}
return String(childPath.dropFirst(rootPath.count + 1))
}
nonisolated private static func displayName(for item: MinecraftContentItem) -> String {
guard item.contentKind == .world else {
return item.folderName
}
let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
guard
let value = try? String(contentsOf: levelNameURL, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
return item.folderName
}
return value
}
}
private let packReferenceIndexStore = PackReferenceIndexStore()

View File

@ -0,0 +1,422 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
nonisolated struct JavaArchiveMetadata: Hashable, Sendable {
var displayName: String?
var pack: JavaPackMetadata?
var mod: JavaModMetadata?
var iconEntryPath: String?
}
enum JavaContentMetadataReader {
nonisolated static func metadata(for item: MinecraftContentItem) -> JavaArchiveMetadata? {
let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
if values?.isDirectory == true {
return directoryMetadata(for: item)
}
if values?.isRegularFile == true {
return archiveMetadata(for: item.folderURL, contentKind: item.contentKind)
}
return nil
}
nonisolated static func cachedIconURL(for item: MinecraftContentItem, metadata: JavaArchiveMetadata?) async -> URL? {
let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
if values?.isDirectory == true {
return await ImageCacheStore.shared.cachedImageURL(for: directoryIconURL(for: item))
}
guard
values?.isRegularFile == true,
let metadata,
let iconEntryPath = metadata.iconEntryPath,
let archive = try? ZipArchiveReader(url: item.folderURL),
let entry = archive.entry(named: iconEntryPath),
let data = try? archive.extract(entry)
else {
return nil
}
return await ImageCacheStore.shared.cachedImageURL(
forRemoteData: data,
cacheKey: "java-archive-icon:\(item.folderURL.standardizedFileURL.path):\(iconEntryPath)",
pathExtension: URL(fileURLWithPath: iconEntryPath).pathExtension
)
}
nonisolated private static func directoryMetadata(for item: MinecraftContentItem) -> JavaArchiveMetadata {
let pack = packMetadata(from: item.folderURL.appendingPathComponent("pack.mcmeta"))
let iconURL = directoryIconURL(for: item)
return JavaArchiveMetadata(
displayName: nil,
pack: pack,
mod: nil,
iconEntryPath: iconURL?.lastPathComponent
)
}
nonisolated private static func archiveMetadata(for archiveURL: URL, contentKind: MinecraftContentKind) -> JavaArchiveMetadata? {
guard let archive = try? ZipArchiveReader(url: archiveURL) else {
return nil
}
let pack = packMetadata(from: archive)
let modMetadata = contentKind == .mod ? modMetadata(from: archive) : nil
let iconEntryPath = iconEntryPath(
in: archive,
preferredPath: modMetadata?.iconPath,
contentKind: contentKind
)
return JavaArchiveMetadata(
displayName: modMetadata?.displayName,
pack: pack,
mod: modMetadata?.metadata,
iconEntryPath: iconEntryPath
)
}
nonisolated private static func directoryIconURL(for item: MinecraftContentItem) -> URL? {
let candidateNames: [String]
switch item.contentKind {
case .mod:
candidateNames = ["icon.png", "logo.png", "mod_logo.png", "catalogue_icon.png", "pack.png"]
case .resourcePack, .dataPack, .shaderPack:
candidateNames = ["pack.png", "icon.png", "logo.png"]
case .world, .behaviorPack, .skinPack, .worldTemplate:
candidateNames = ["icon.png", "pack.png"]
}
for candidateName in candidateNames {
let candidateURL = item.folderURL.appendingPathComponent(candidateName)
if FileManager.default.fileExists(atPath: candidateURL.path) {
return candidateURL
}
}
return nil
}
nonisolated private static func packMetadata(from metadataURL: URL) -> JavaPackMetadata? {
guard let data = try? Data(contentsOf: metadataURL) else {
return nil
}
return packMetadata(from: data)
}
nonisolated private static func packMetadata(from archive: ZipArchiveReader) -> JavaPackMetadata? {
guard
let entry = archive.entry(named: "pack.mcmeta"),
let data = try? archive.extract(entry)
else {
return nil
}
return packMetadata(from: data)
}
nonisolated private static func packMetadata(from data: Data) -> JavaPackMetadata? {
guard
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let packObject = jsonObject["pack"] as? [String: Any]
else {
return nil
}
return JavaPackMetadata(
packFormat: packObject["pack_format"] as? Int,
supportedFormats: supportedFormatsValue(from: packObject["supported_formats"]),
description: textValue(from: packObject["description"])
)
}
nonisolated private static func modMetadata(
from archive: ZipArchiveReader
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
if let tomlMetadata = modTOMLMetadata(from: archive) {
return tomlMetadata
}
if let jsonMetadata = modJSONMetadata(from: archive, entryName: "fabric.mod.json") {
return jsonMetadata
}
if let jsonMetadata = modJSONMetadata(from: archive, entryName: "quilt.mod.json") {
return jsonMetadata
}
return nil
}
nonisolated private static func modTOMLMetadata(
from archive: ZipArchiveReader
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
let entryNames = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"]
for entryName in entryNames {
guard
let entry = archive.entry(named: entryName),
let data = try? archive.extract(entry),
let text = String(data: data, encoding: .utf8)
else {
continue
}
let firstModSection = firstTOMLSection(named: "[[mods]]", in: text)
let dependenciesSection = firstTOMLSection(named: "[[dependencies.", in: text)
let displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
let metadata = JavaModMetadata(
modID: tomlStringValue(forKey: "modId", in: firstModSection),
version: tomlStringValue(forKey: "version", in: firstModSection),
description: tomlStringValue(forKey: "description", in: firstModSection),
authors: stringListValue(from: tomlStringValue(forKey: "authors", in: firstModSection)),
license: tomlStringValue(forKey: "license", in: text),
environment: nil,
minecraftVersionRequirement: minecraftDependencyRequirement(fromTOMLSection: dependenciesSection)
)
if displayName != nil || logoFile != nil || metadata.hasValues {
return (displayName, logoFile, metadata)
}
}
return nil
}
nonisolated private static func modJSONMetadata(
from archive: ZipArchiveReader,
entryName: String
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
guard
let entry = archive.entry(named: entryName),
let data = try? archive.extract(entry),
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return nil
}
let iconPath: String?
if let iconString = jsonObject["icon"] as? String {
iconPath = iconString
} else if let icons = jsonObject["icon"] as? [String: String] {
iconPath = icons.sorted { lhs, rhs in lhs.key.localizedStandardCompare(rhs.key) == .orderedDescending }.first?.value
} else {
iconPath = nil
}
let metadata = JavaModMetadata(
modID: (jsonObject["id"] as? String)?.nilIfBlank,
version: (jsonObject["version"] as? String)?.nilIfBlank,
description: textValue(from: jsonObject["description"]),
authors: authorsValue(from: jsonObject["authors"]),
license: licenseValue(from: jsonObject["license"]),
environment: (jsonObject["environment"] as? String)?.nilIfBlank,
minecraftVersionRequirement: minecraftDependencyRequirement(fromJSON: jsonObject)
)
return (
(jsonObject["name"] as? String)?.nilIfBlank,
iconPath?.nilIfBlank,
metadata
)
}
nonisolated private static func firstTOMLSection(named sectionName: String, in text: String) -> String {
guard let sectionRange = text.range(of: sectionName) else {
return text
}
let sectionText = text[sectionRange.upperBound...]
if let nextSectionRange = sectionText.range(of: "\n[") {
return String(sectionText[..<nextSectionRange.lowerBound])
}
return String(sectionText)
}
nonisolated private static func tomlStringValue(forKey key: String, in text: String) -> String? {
for rawLine in text.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard line.hasPrefix(key) else {
continue
}
let parts = line.split(separator: "=", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
continue
}
return parts[1]
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
.nilIfBlank
}
return nil
}
nonisolated private static func minecraftDependencyRequirement(fromTOMLSection text: String) -> String? {
guard tomlStringValue(forKey: "modId", in: text) == "minecraft" else {
return nil
}
return tomlStringValue(forKey: "versionRange", in: text)
}
nonisolated private static func minecraftDependencyRequirement(fromJSON jsonObject: [String: Any]) -> String? {
for key in ["depends", "dependencies", "breaks"] {
guard let dependencies = jsonObject[key] as? [String: Any] else {
continue
}
if let minecraft = dependencies["minecraft"] as? String {
return minecraft.nilIfBlank
}
if let minecraft = dependencies["minecraft"] as? [String: Any] {
return textValue(from: minecraft["version"])
}
}
return nil
}
nonisolated private static func authorsValue(from value: Any?) -> [String] {
if let author = value as? String {
return stringListValue(from: author)
}
if let authors = value as? [String] {
return authors.compactMap(\.nilIfBlank)
}
if let authors = value as? [[String: Any]] {
return authors.compactMap { author in
textValue(from: author["name"])
}
}
return []
}
nonisolated private static func licenseValue(from value: Any?) -> String? {
if let license = value as? String {
return license.nilIfBlank
}
if let licenses = value as? [String] {
let values = licenses.compactMap(\.nilIfBlank)
return values.isEmpty ? nil : values.joined(separator: ", ")
}
return nil
}
nonisolated private static func stringListValue(from value: String?) -> [String] {
guard let value else {
return []
}
return value
.split { character in
character == "," || character == ";"
}
.map(String.init)
.compactMap(\.nilIfBlank)
}
nonisolated private static func iconEntryPath(
in archive: ZipArchiveReader,
preferredPath: String?,
contentKind: MinecraftContentKind
) -> String? {
let candidateNames: [String]
switch contentKind {
case .mod:
candidateNames = [preferredPath, "icon.png", "logo.png", "mod_logo.png", "catalogue_icon.png", "pack.png"].compactMap(\.self)
case .resourcePack, .dataPack, .shaderPack:
candidateNames = [preferredPath, "pack.png", "icon.png", "logo.png"].compactMap(\.self)
case .world, .behaviorPack, .skinPack, .worldTemplate:
candidateNames = [preferredPath, "icon.png", "pack.png"].compactMap(\.self)
}
for candidateName in candidateNames {
if let entry = archive.entry(named: candidateName), !entry.isDirectory {
return entry.path
}
}
return archive.entries
.filter { !$0.isDirectory && $0.path.localizedCaseInsensitiveContains("icon") && $0.path.hasSuffix(".png") }
.sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending }
.first?
.path
}
nonisolated private static func textValue(from value: Any?) -> String? {
if let text = value as? String {
return text.nilIfBlank
}
if let object = value as? [String: Any] {
if let text = object["text"] as? String {
return text.nilIfBlank
}
if let translate = object["translate"] as? String {
return translate.nilIfBlank
}
}
return nil
}
nonisolated private static func supportedFormatsValue(from value: Any?) -> String? {
if let format = value as? Int {
return String(format)
}
if let formats = value as? [Int] {
return formats.map(String.init).joined(separator: ", ").nilIfBlank
}
if let object = value as? [String: Any] {
let minValue = object["min_inclusive"] as? Int
let maxValue = object["max_inclusive"] as? Int
switch (minValue, maxValue) {
case (.some(let minValue), .some(let maxValue)):
return "\(minValue)-\(maxValue)"
case (.some(let minValue), .none):
return "\(minValue)+"
case (.none, .some(let maxValue)):
return "Up to \(maxValue)"
case (.none, .none):
return nil
}
}
return nil
}
}
private extension JavaModMetadata {
nonisolated var hasValues: Bool {
modID != nil
|| version != nil
|| description != nil
|| !authors.isEmpty
|| license != nil
|| environment != nil
|| minecraftVersionRequirement != nil
}
}
private extension String {
nonisolated var nilIfBlank: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -10,7 +10,9 @@ struct MinecraftManifestMetadata: Sendable, Hashable {
let minimumEngineVersion: String?
}
enum MinecraftContentMetadataReader {
typealias MinecraftContentMetadataReader = BedrockContentMetadataReader
enum BedrockContentMetadataReader {
nonisolated static func displayName(
for directoryURL: URL,
contentType: MinecraftContentType,

View File

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

View File

@ -11,14 +11,12 @@ 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)
@ -30,8 +28,6 @@ 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):
@ -45,7 +41,7 @@ nonisolated struct ZipArchiveReader {
let entries: [ZipArchiveEntry]
init(url: URL) throws {
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
self.data = try Data(contentsOf: url)
self.entries = try ZipArchiveReader.parseEntries(in: data)
}
@ -82,9 +78,6 @@ 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))
@ -122,7 +115,6 @@ 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
@ -135,9 +127,7 @@ 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,
@ -145,8 +135,7 @@ nonisolated struct ZipArchiveReader {
compressedSize: compressedSize,
uncompressedSize: uncompressedSize,
localHeaderOffset: localHeaderOffset,
isDirectory: normalizedPath.hasSuffix("/"),
isSymbolicLink: unixMode & 0o170000 == 0o120000
isDirectory: normalizedPath.hasSuffix("/")
)
)
@ -189,18 +178,6 @@ 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()
@ -251,17 +228,11 @@ nonisolated struct ZipArchiveReader {
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
if producedByteCount > 0 {
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
guard output.count <= expectedSize else {
throw ZipArchiveReaderError.invalidArchive
}
}
}
} while status != Z_STREAM_END
}
guard output.count == expectedSize else {
throw ZipArchiveReaderError.invalidArchive
}
return output
}
}

View File

@ -6,6 +6,11 @@ import Foundation
import OSLog
import UniformTypeIdentifiers
enum SourceLibraryCommand: Sendable {
case discoverSourceCandidates
case refreshAllSources
}
@MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting {
private static let enrichmentWorkerCount = 4
@ -28,11 +33,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
}
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var sourceCandidates: [SourceCandidate] = []
@Published var isDiscoveringSourceCandidates = false
@Published var isRestoringPersistedSources = true
@Published var installationStateBySourceID: [URL: SourceInstallationState] = [:]
private var scanTasks: [URL: Task<Void, Never>] = [:]
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
private var candidateDiscoveryTask: Task<Void, Never>?
private var connectedDeviceRefreshTask: Task<Void, Never>?
private var localSourceRefreshTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore
@ -52,28 +59,35 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
notificationService: ScanNotificationServicing? = nil,
itemActionService: ContentItemActionService = ContentItemActionService()
itemActionService: ContentItemActionService = ContentItemActionService(),
restoresPersistedSources: Bool = true,
startsBackgroundRefresh: Bool = true
) {
self.persistenceStore = persistenceStore
self.sourceAccessMethod = sourceAccessMethod
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
self.notificationService = notificationService ?? ScanNotificationService.shared
self.itemActionService = itemActionService
self.isRestoringPersistedSources = restoresPersistedSources
Task { [weak self] in
guard let self else {
return
if restoresPersistedSources {
Task { [weak self] in
guard let self else {
return
}
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
}
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
}
localSourceRefreshTask = Task { [weak self] in
await self?.runLocalSourceRefreshLoop()
}
if startsBackgroundRefresh {
localSourceRefreshTask = Task { [weak self] in
await self?.runLocalSourceRefreshLoop()
}
if connectedDeviceAccessMethod != nil {
connectedDeviceRefreshTask = Task { [weak self] in
await self?.runConnectedDeviceRefreshLoop()
if connectedDeviceAccessMethod != nil {
connectedDeviceRefreshTask = Task { [weak self] in
await self?.runConnectedDeviceRefreshLoop()
}
}
}
}
@ -81,6 +95,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
deinit {
connectedDeviceRefreshTask?.cancel()
localSourceRefreshTask?.cancel()
candidateDiscoveryTask?.cancel()
automaticSyncTasks.values.forEach { $0.cancel() }
scanTasks.values.forEach { $0.cancel() }
}
@ -93,6 +108,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
visibleSources
}
var sidebarConnectedDevices: [ConnectedDeviceSidebarEntry] {
connectedDevices.filter { entry in
guard entry.matchedSourceID == nil else {
return false
}
return !sources.contains { source in
guard case .connectedDevice(let device, _) = source.origin else {
return false
}
return device.udid == entry.device.udid
}
}
}
func sourceID(forItemID itemID: URL) -> URL? {
sourceIDByItemID[itemID]
}
@ -111,6 +142,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
connectedDeviceRefreshTask = nil
localSourceRefreshTask?.cancel()
localSourceRefreshTask = nil
candidateDiscoveryTask?.cancel()
candidateDiscoveryTask = nil
for task in automaticSyncTasks.values {
task.cancel()
@ -138,40 +171,121 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
)
}
func addSource(at url: URL) -> URL {
let normalizedURL = url.standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
func perform(_ command: SourceLibraryCommand) {
switch command {
case .discoverSourceCandidates:
discoverSourceCandidates()
case .refreshAllSources:
for source in visibleSources where source.availability == .available {
startScan(for: source.id, mode: .fullScan)
}
}
}
if sources.contains(where: { $0.id == normalizedURL }) {
updateSource(normalizedURL) { source in
func addSource(at url: URL) async -> URL {
let selectedURL = url.standardizedFileURL
let probe = await sourceAccessMethod.probeLocalFolder(selectedURL)
let normalizedURL = (probe?.sourceRootURL ?? selectedURL).standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL) ?? securityScopedBookmarkData(for: selectedURL)
let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier
let edition = probe?.edition ?? .bedrock
if let existingSourceID = existingSourceID(matching: normalizedURL) {
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) }
updateSource(existingSourceID) { source in
if source.bookmarkData == nil {
source.bookmarkData = bookmarkData
}
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
source.accessDescriptor = SourceAccessDescriptor(
accessorIdentifier: providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
source.providerID = providerID
source.edition = edition
source.capabilities = source.origin.defaultCapabilities
if let probe {
source.displayName = probe.displayName
if let warning = probe.warnings.first {
source.scanDiagnostic = warning
}
}
}
startScan(for: normalizedURL, mode: .fullScan)
return normalizedURL
startScan(for: existingSourceID, mode: .fullScan)
return existingSourceID
}
let source = MinecraftSource(
var source = MinecraftSource(
folderURL: normalizedURL,
bookmarkData: bookmarkData,
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier,
accessorIdentifier: providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
)
return addSource(source, shouldPersist: true, shouldScan: true)
source.providerID = providerID
source.edition = edition
source.displayName = probe?.displayName ?? normalizedURL.lastPathComponent
if let warning = probe?.warnings.first {
source.scanDiagnostic = warning
}
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: sourceID) }
return sourceID
}
func addSource(candidate: SourceCandidate) async -> URL {
let normalizedURL = candidate.sourceRootURL.standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
if let existingSourceID = existingSourceID(matching: normalizedURL) {
updateSource(existingSourceID) { source in
if source.bookmarkData == nil {
source.bookmarkData = bookmarkData
}
source.accessDescriptor = SourceAccessDescriptor(
accessorIdentifier: candidate.providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
source.providerID = candidate.providerID
source.edition = candidate.edition
source.displayName = candidate.displayName
source.capabilities = source.origin.defaultCapabilities
}
removeSourceCandidates(matching: candidate, sourceID: existingSourceID)
startScan(for: existingSourceID, mode: .fullScan)
return existingSourceID
}
var source = MinecraftSource(
folderURL: normalizedURL,
bookmarkData: bookmarkData,
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: candidate.providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
)
source.providerID = candidate.providerID
source.edition = candidate.edition
source.displayName = candidate.displayName
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
removeSourceCandidates(matching: candidate, sourceID: sourceID)
return sourceID
}
@discardableResult
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
if sources.contains(where: { $0.id == source.id }) {
updateSource(source.id) { existingSource in
if let existingSourceID = existingSourceID(matching: source.id) {
updateSource(existingSourceID) { existingSource in
existingSource.origin = source.origin
existingSource.accessDescriptor = source.accessDescriptor
existingSource.providerID = source.accessDescriptor.accessorIdentifier
existingSource.edition = source.edition
existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName)
existingSource.availability = source.availability
existingSource.capabilities = source.capabilities
if existingSource.bookmarkData == nil {
@ -184,19 +298,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} else {
var resolvedSource = source
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier
resolvedSource.edition = source.edition
resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName)
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
sources.append(resolvedSource)
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
}
if shouldPersist {
persistSourceIfAvailable(withID: source.id)
persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id)
}
if shouldScan {
startScan(for: source.id, mode: .fullScan)
startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan)
}
return source.id
return existingSourceID(matching: source.id) ?? source.id
}
func source(withID sourceID: URL) -> MinecraftSource? {
@ -211,6 +328,42 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
startScan(for: sourceID, mode: .fullScan)
}
func discoverSourceCandidates() {
candidateDiscoveryTask?.cancel()
isDiscoveringSourceCandidates = true
sourceCandidates.removeAll { candidateAlreadyAdded($0) }
let task = Task { [weak self] in
guard let self else {
return
}
defer {
self.isDiscoveringSourceCandidates = false
self.candidateDiscoveryTask = nil
}
do {
for try await event in self.sourceAccessMethod.discoverSourceCandidates() {
guard !Task.isCancelled else {
return
}
switch event {
case .candidate(let candidate):
self.recordSourceCandidate(candidate)
case .stageUpdated, .warning:
break
}
}
} catch {
return
}
}
candidateDiscoveryTask = task
}
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
try await sourceAccessMethod.listItemContents(for: item, in: source)
}
@ -251,143 +404,6 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
}
func installPackages(at packageURLs: [URL], into sourceID: URL) async throws -> [InstalledContentItem] {
guard
let destinationSource = source(withID: sourceID),
destinationSource.availability == .available,
destinationSource.capabilities.canInstallItems
else {
throw SourceAccessError.accessFailed(reason: "This source is not currently available for installation.")
}
guard !packageURLs.isEmpty else {
return []
}
scanTasks[sourceID]?.cancel()
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: 0,
totalCount: packageURLs.count,
status: "Preparing import..."
)
var plans: [InstallationPlan] = []
var installedItems: [InstalledContentItem] = []
var totalPayloadCount = 0
do {
for packageURL in packageURLs {
let plan = try await Task.detached(priority: .userInitiated) {
let accessedSecurityScope = packageURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
packageURL.stopAccessingSecurityScopedResource()
}
}
return try MinecraftPackageInstaller.preparePackage(at: packageURL)
}.value
plans.append(plan)
}
let payloads = plans.flatMap(\.payloads)
totalPayloadCount = payloads.count
try validatePackConflicts(payloads, destinationSource: destinationSource)
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: 0,
totalCount: payloads.count,
status: payloads.count == 1 ? "Installing item..." : "Installing \(payloads.count) items..."
)
for payload in payloads {
guard let currentDestination = source(withID: sourceID) else {
throw SourceAccessError.accessFailed(reason: "The destination source was removed during installation.")
}
let installedItem = try await sourceAccessMethod.install(payload, in: currentDestination)
installedItems.append(installedItem)
installationStateBySourceID[sourceID] = SourceInstallationState(
completedCount: installedItems.count,
totalCount: payloads.count,
status: "Installed \(installedItems.count) of \(payloads.count)..."
)
}
plans.forEach { $0.cleanup() }
installationStateBySourceID[sourceID] = nil
startScan(for: sourceID, mode: .fullScan)
return installedItems
} catch {
plans.forEach { $0.cleanup() }
installationStateBySourceID[sourceID] = nil
if !installedItems.isEmpty {
startScan(for: sourceID, mode: .fullScan)
throw MinecraftPackageInstaller.InstallError.partialInstallation(
completed: installedItems.count,
total: totalPayloadCount,
reason: error.localizedDescription
)
}
throw error
}
}
func copyItem(
_ item: MinecraftContentItem,
from sourceID: URL,
into destinationSourceID: URL
) async throws -> [InstalledContentItem] {
guard let source = source(withID: sourceID) else {
throw SourceAccessError.accessFailed(reason: "The source item is no longer available.")
}
let representation = try await externalRepresentation(
for: item,
in: source,
preferredKind: .portablePackage
)
defer {
if representation.isTemporary {
try? FileManager.default.removeItem(at: representation.url)
}
}
return try await installPackages(at: [representation.url], into: destinationSourceID)
}
private func validatePackConflicts(
_ payloads: [InstallationPayload],
destinationSource: MinecraftSource
) throws {
var existingIdentities = Set(
destinationSource.rawItems.compactMap { item -> String? in
guard
item.contentType == .behaviorPack ||
item.contentType == .resourcePack ||
item.contentType == .skinPack,
let uuid = item.packUUID?.lowercased()
else {
return nil
}
return "\(item.contentType.rawValue)::\(uuid)"
}
)
for payload in payloads {
guard
payload.contentType == .behaviorPack ||
payload.contentType == .resourcePack ||
payload.contentType == .skinPack,
let uuid = payload.packUUID?.lowercased()
else {
continue
}
let identity = "\(payload.contentType.rawValue)::\(uuid)"
guard !existingIdentities.contains(identity) else {
throw MinecraftPackageInstaller.InstallError.duplicatePack(
name: payload.displayName,
version: payload.packVersion
)
}
existingIdentities.insert(identity)
}
}
func removeSource(withID sourceID: URL) {
let removedSource = source(withID: sourceID)
scanTasks[sourceID]?.cancel()
@ -458,6 +474,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.worldPackRelationships = index.worldPackRelationships
source.displayItems = index.displayItems
source.displayItemCountsByType = index.displayItemCountsByType
source.displayItemCountsByKind = index.displayItemCountsByKind
}
}
@ -500,6 +517,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
updateSource(sourceID) { source in
source.displayItems = snapshot.displayItems
source.displayItemCountsByType = snapshot.displayItemCountsByType
source.displayItemCountsByKind = snapshot.displayItemCountsByKind
source.rawItems = snapshot.rawItems
source.logicalPacks = snapshot.logicalPacks
source.logicalWorlds = snapshot.logicalWorlds
@ -570,8 +588,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
}
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
WorldScanner.collectionSnapshots(in: sourceURL)
func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] {
switch edition {
case .bedrock:
return WorldScanner.collectionSnapshots(in: sourceURL)
case .java:
return JavaContentScanner.collectionSnapshots(in: sourceURL)
}
}
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
@ -661,6 +684,54 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
}
sourceIDByItemID = itemIndex
sourceCandidates.removeAll { candidateAlreadyAdded($0) }
}
private func recordSourceCandidate(_ candidate: SourceCandidate) {
guard !candidateAlreadyAdded(candidate) else {
return
}
if let existingIndex = sourceCandidates.firstIndex(where: { $0.id == candidate.id }) {
if candidate.confidence > sourceCandidates[existingIndex].confidence {
sourceCandidates[existingIndex] = candidate
}
} else {
sourceCandidates.append(candidate)
}
sourceCandidates.sort {
if $0.confidence != $1.confidence {
return $0.confidence > $1.confidence
}
return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
}
}
private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool {
sources.contains { source in
sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL)
|| sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL)
}
}
private func existingSourceID(matching url: URL) -> URL? {
let identity = sourceIdentityKey(for: url)
return sources.first { source in
sourceIdentityKey(for: source.id) == identity
|| sourceIdentityKey(for: source.folderURL) == identity
}?.id
}
private func removeSourceCandidates(matching candidate: SourceCandidate, sourceID: URL) {
let candidateIdentity = sourceIdentityKey(for: candidate.sourceRootURL)
let sourceIdentity = sourceIdentityKey(for: sourceID)
sourceCandidates.removeAll {
$0.id == candidate.id
|| sourceIdentityKey(for: $0.sourceRootURL) == candidateIdentity
|| sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentity
}
}
@discardableResult

View File

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

View File

@ -12,7 +12,7 @@ protocol LocalSourceRuntimeHosting: AnyObject {
func source(withID sourceID: URL) -> MinecraftSource?
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool)
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot]
func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot]
}
enum LocalSourceRuntime {
@ -86,7 +86,7 @@ enum LocalSourceRuntime {
if SourceRestoration.needsReconcile(
refreshedSource,
currentCollectionSnapshots: host.currentCollectionSnapshots(for:)
currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:)
) {
host.queueAutomaticSync(
for: sourceID,

View File

@ -15,7 +15,7 @@ protocol SourcePersistenceHosting: AnyObject {
func refreshConnectedDevices() async
func refreshLocalSources() async
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot]
func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot]
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String
}
@ -125,7 +125,7 @@ enum SourcePersistenceCoordinator {
if let refreshReason = SourceRestoration.startupRefreshReason(
for: source,
persistedRecord: persistedRecordsByID[source.id],
currentCollectionSnapshots: host.currentCollectionSnapshots(for:)
currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:)
) {
host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil)
}

View File

@ -16,6 +16,8 @@ enum SourceRestoration {
accessDescriptor: record.accessDescriptor,
availability: record.availability
)
source.providerID = record.accessDescriptor.accessorIdentifier
source.edition = edition(for: record.accessDescriptor, origin: record.origin)
if case .connectedDevice(let device, let container) = source.origin {
var repairedDevice = device
@ -56,6 +58,9 @@ enum SourceRestoration {
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.indexedItemCount = items.count
source.indexedDetailCount = items.filter(\.metadataLoaded).count
source.previewLoadedCount = items.filter(\.previewLoaded).count
@ -101,7 +106,7 @@ enum SourceRestoration {
static func startupRefreshReason(
for source: MinecraftSource,
persistedRecord: PersistedSourceRecord?,
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
) -> String? {
guard source.availability == .available else {
return nil
@ -130,7 +135,7 @@ enum SourceRestoration {
static func needsReconcile(
_ source: MinecraftSource,
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
) -> Bool {
reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots)
}
@ -149,7 +154,7 @@ enum SourceRestoration {
private static func needsRescan(
_ record: PersistedSourceRecord,
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
) -> Bool {
guard record.accessDescriptor.refreshStrategy == .eagerFullScan else {
return record.rawItems.isEmpty
@ -164,15 +169,16 @@ enum SourceRestoration {
return true
}
let edition = edition(for: record.accessDescriptor, origin: record.origin)
return collectionsDiffer(
currentCollectionSnapshots(sourceURL),
currentCollectionSnapshots(sourceURL, edition),
persistedCollections: snapshot.collectionSnapshots
)
}
private static func reconcileIsNeeded(
_ source: MinecraftSource,
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
) -> Bool {
guard source.accessDescriptor.refreshStrategy == .eagerFullScan else {
return source.rawItems.isEmpty
@ -188,29 +194,38 @@ enum SourceRestoration {
}
return collectionsDiffer(
currentCollectionSnapshots(sourceURL),
currentCollectionSnapshots(sourceURL, source.edition),
persistedCollections: snapshot.collectionSnapshots
)
}
private static func edition(
for accessDescriptor: SourceAccessDescriptor,
origin: MinecraftSourceOrigin
) -> MinecraftEdition {
if accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier {
return .java
}
return origin.defaultEdition
}
private static func collectionsDiffer(
_ currentCollections: [CollectionSnapshot],
persistedCollections: [CollectionSnapshot]
) -> Bool {
let currentCollectionsByName = Dictionary(
uniqueKeysWithValues: currentCollections.map { ($0.folderName, $0) }
)
let persistedCollectionsByName = Dictionary(
uniqueKeysWithValues: persistedCollections.map { ($0.folderName, $0) }
)
let currentCollectionsByName = Dictionary(grouping: currentCollections, by: \.folderName)
.mapValues { $0.map(\.fingerprint).sorted() }
let persistedCollectionsByName = Dictionary(grouping: persistedCollections, by: \.folderName)
.mapValues { $0.map(\.fingerprint).sorted() }
if currentCollectionsByName.count != persistedCollectionsByName.count {
return true
}
for (folderName, persistedCollection) in persistedCollectionsByName {
guard let currentCollection = currentCollectionsByName[folderName],
currentCollection.fingerprint == persistedCollection.fingerprint else {
for (folderName, persistedFingerprints) in persistedCollectionsByName {
guard let currentFingerprints = currentCollectionsByName[folderName],
currentFingerprints == persistedFingerprints else {
return true
}
}

View File

@ -11,6 +11,7 @@ struct SourceContentIndex {
let worldPackRelationships: [WorldPackRelationship]
let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
let displayItemCountsByKind: [MinecraftContentKind: Int]
}
enum SourceContentIndexer {
@ -171,7 +172,8 @@ enum SourceContentIndexer {
packInstances: sortedPackInstances,
worldPackRelationships: worldRelationships,
displayItems: displayItems,
displayItemCountsByType: displayItemCounts(for: displayItems)
displayItemCountsByType: displayItemCounts(for: displayItems),
displayItemCountsByKind: displayItemKindCounts(for: displayItems)
)
}
@ -219,6 +221,12 @@ enum SourceContentIndexer {
}
}
private static func displayItemKindCounts(for items: [MinecraftContentItem]) -> [MinecraftContentKind: Int] {
items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
}
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
let candidateEmbedded = isEmbeddedWorldPack(candidate)
let existingEmbedded = isEmbeddedWorldPack(existing)

View File

@ -50,9 +50,10 @@ enum SourceScanExecutor {
host.updateSource(sourceID) { source in
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
}
let currentAvailability = await sourceAccessMethod.availability(for: source)
let currentAccessStatus = await sourceAccessMethod.accessStatus(for: source)
host.updateSource(sourceID) { source in
source.availability = currentAvailability
source.accessStatus = currentAccessStatus
source.availability = currentAccessStatus.availability
}
let scanContextURL = source.folderURL
@ -89,22 +90,7 @@ enum SourceScanExecutor {
}
}
}
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
let discoveryTask = Task.detached(priority: .userInitiated) {
do {
try await sourceAccessMethod.discoverItems(for: source, mode: mode) { item in
continuation.yield(item)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
discoveryTask.cancel()
}
}
let providerEventStream = sourceAccessMethod.scanEvents(for: source, mode: mode)
let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) })
let previousSnapshotByItemID = Dictionary(
@ -116,35 +102,61 @@ enum SourceScanExecutor {
var discoveredCollectionNames = Set<String>()
let discoveryStartTime = Date()
for try await item in discoveryStream {
for try await event in providerEventStream {
guard !Task.isCancelled else {
break
}
discoveredCount += 1
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
let itemForIndex: MinecraftContentItem
if shouldReconcileFromCache,
let cachedItem = previousItemsByID[item.id],
SourceScanPolicy.shouldReuseCachedItem(
cachedItem,
forDiscoveredItem: item,
source: source,
previousSnapshot: previousSnapshotByItemID[item.id]
) {
itemForIndex = cachedItem
} else {
itemForIndex = item
}
switch event {
case .accessStatusChanged(let accessStatus):
host.updateSource(sourceID) { source in
source.accessStatus = accessStatus
source.availability = accessStatus.availability
}
continue
case .stageUpdated(let stage):
host.updateSource(sourceID) { source in
source.scanStatus = stage.detail ?? stage.title
}
continue
case .warning(let warning):
host.updateSource(sourceID) { source in
source.scanDiagnostic = warning.detail ?? warning.message
}
continue
case .inspected(let inspectedItem):
if let snapshot = await index.applyEnrichedItem(inspectedItem) {
await MainActor.run {
host.applySnapshot(snapshot, to: sourceID)
}
}
continue
case .discovered(let item):
discoveredCount += 1
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
let itemForIndex: MinecraftContentItem
if shouldReconcileFromCache,
let cachedItem = previousItemsByID[item.id],
SourceScanPolicy.shouldReuseCachedItem(
cachedItem,
forDiscoveredItem: item,
source: source,
previousSnapshot: previousSnapshotByItemID[item.id]
) {
itemForIndex = cachedItem
} else {
itemForIndex = item
}
if let snapshot = await index.addDiscoveredItem(
itemForIndex,
discoveredCount: discoveredCount
) {
host.applySnapshot(snapshot, to: sourceID)
}
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
await enrichmentQueue.enqueue(item)
if let snapshot = await index.addDiscoveredItem(
itemForIndex,
discoveredCount: discoveredCount
) {
host.applySnapshot(snapshot, to: sourceID)
}
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
await enrichmentQueue.enqueue(item)
}
}
}
@ -227,6 +239,23 @@ 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) {
@ -305,6 +334,52 @@ enum SourceScanExecutor {
}
}
private static func finishConnectedDeviceScan(
sourceID: URL,
source: MinecraftSource,
host: SourceScanSessionHosting,
sourceAccessMethod: SourceAccessMethod,
notificationService: ScanNotificationServicing,
index: SourceIndexActor,
discoveredCount: Int,
scanStartTime: Date,
scanContextURL: URL,
performanceContext: String,
minimumVisibleScanDuration: TimeInterval
) async throws {
let sizeStageStartTime = Date()
let sizeSeedItems = await index.currentItems()
let sizedItems = await sourceAccessMethod.loadSizeAssets(
for: sizeSeedItems.filter { !$0.sizeLoaded },
in: source
)
for sizedItem in sizedItems {
if let snapshot = await index.applySizedItem(sizedItem) {
host.applySnapshot(snapshot, to: sourceID)
}
}
host.logScanStage(
"Size",
elapsed: Date().timeIntervalSince(sizeStageStartTime),
context: performanceContext,
itemCount: discoveredCount
)
try await finishScan(
sourceID: sourceID,
source: source,
host: host,
notificationService: notificationService,
index: index,
discoveredCount: discoveredCount,
scanStartTime: scanStartTime,
scanContextURL: scanContextURL,
performanceContext: performanceContext,
minimumVisibleScanDuration: minimumVisibleScanDuration
)
}
private static func finishScan(
sourceID: URL,
source: MinecraftSource,
@ -401,6 +476,7 @@ private actor EnrichmentWorkQueue {
struct SourceIndexSnapshot {
let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
let displayItemCountsByKind: [MinecraftContentKind: Int]
let rawItems: [MinecraftContentItem]
let logicalPacks: [LogicalPack]
let logicalWorlds: [LogicalWorld]
@ -568,6 +644,9 @@ private actor SourceIndexActor {
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
let displayItemCountsByKind = dedupedDisplayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
@ -589,6 +668,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -617,6 +697,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -649,6 +730,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -750,8 +832,7 @@ private actor SourceIndexActor {
} else if sizeLoadedCount == 0 {
scanStatus = "Preparing size calculations..."
} else {
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
}
} else {
scanStatus = indexedItemCount == 0
@ -762,6 +843,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: logicalWorlds,

View File

@ -6,9 +6,9 @@ import Foundation
enum SourceScanPolicy {
static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan):
return "Preparing folder scan..."
case (.localFolder, .reconcile):
case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile):
return "Preparing cached library refresh..."
case (.connectedDevice, .fullScan):
return "Connecting to device and discovering Minecraft items..."
@ -19,9 +19,9 @@ enum SourceScanPolicy {
static func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan):
return "Scanning Minecraft library..."
case (.localFolder, .reconcile):
case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile):
return "Reconciling cached library..."
case (.connectedDevice, .fullScan):
return "Scanning Minecraft library on device..."
@ -32,7 +32,7 @@ enum SourceScanPolicy {
static func performanceContext(for source: MinecraftSource) -> String {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return "source=\(source.displayName) kind=local"
case .connectedDevice(let device, let container):
let transport = device.connection == .usb ? "usb" : "network"
@ -121,7 +121,13 @@ enum SourceScanPolicy {
}
static func buildSnapshot(for source: MinecraftSource, scanRootURL: URL) -> SourceSnapshot {
let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
let collectionSnapshots: [CollectionSnapshot]
switch source.edition {
case .bedrock:
collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
case .java:
collectionSnapshots = JavaContentScanner.collectionSnapshots(in: scanRootURL)
}
let itemSnapshots = source.rawItems.map { item in
ItemSnapshot(
@ -153,6 +159,7 @@ enum SourceScanRecovery {
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
source.displayItems = previousSource.displayItems
source.displayItemCountsByType = previousSource.displayItemCountsByType
source.displayItemCountsByKind = previousSource.displayItemCountsByKind
source.rawItems = previousSource.rawItems
source.logicalPacks = previousSource.logicalPacks
source.logicalWorlds = previousSource.logicalWorlds

View File

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

View File

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

View File

@ -122,11 +122,7 @@ 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;
@ -163,11 +159,7 @@ typedef struct {
AFCKeyValueCloseFn AFCKeyValueClose;
AFCFileRefOpenFn AFCFileRefOpen;
AFCFileRefReadFn AFCFileRefRead;
AFCFileRefWriteFn AFCFileRefWrite;
AFCFileRefCloseFn AFCFileRefClose;
AFCDirectoryCreateFn AFCDirectoryCreate;
AFCRenamePathFn AFCRenamePath;
AFCRemovePathFn AFCRemovePath;
} WMMMobileDeviceFunctions;
typedef struct {
@ -245,11 +237,7 @@ 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 ||
@ -1048,163 +1036,6 @@ 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:@"/"]) {
@ -2550,122 +2381,3 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
return success;
}
NSString * _Nullable
WMMInstallLocalDirectoryInConnectedDeviceApp(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *minecraftRootRelativePath,
NSString *collectionFolderName,
NSString *preferredDestinationName,
NSURL *sourceDirectoryURL,
NSError **error
) {
if (bundleIdentifier.length == 0 ||
minecraftRootRelativePath.length == 0 ||
collectionFolderName.length == 0 ||
preferredDestinationName.length == 0 ||
sourceDirectoryURL == nil ||
!sourceDirectoryURL.isFileURL) {
if (error != NULL) {
*error = WMMMakeError(16, @"A device, Minecraft path, collection, destination name, and local source directory are required.");
}
return nil;
}
WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) {
return nil;
}
if (functions.AFCFileRefWrite == NULL ||
functions.AFCDirectoryCreate == NULL ||
functions.AFCRenamePath == NULL ||
functions.AFCRemovePath == NULL) {
if (error != NULL) {
*error = WMMMakeError(17, @"This version of MobileDevice.framework does not provide the required AFC write operations.");
}
return nil;
}
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
functions.AMDeviceRelease(device);
return nil;
}
AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
&functions,
device,
bundleIdentifier,
&backingServiceConnection,
error
);
if (afcConnection == NULL) {
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
NSString *minecraftRoot = WMMNormalizedAFCPath(minecraftRootRelativePath);
NSString *stagingRoot = [minecraftRoot stringByAppendingPathComponent:@".world-manager-staging"];
NSString *stagedItemPath = [stagingRoot stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
NSString *collectionPath = [minecraftRoot stringByAppendingPathComponent:collectionFolderName];
BOOL success =
WMMEnsureAFCDirectory(&functions, afcConnection, stagingRoot, error) &&
WMMEnsureAFCDirectory(&functions, afcConnection, stagedItemPath, error) &&
WMMCopyLocalTreeToAFCPath(&functions, afcConnection, sourceDirectoryURL, stagedItemPath, error) &&
WMMEnsureAFCDirectory(&functions, afcConnection, collectionPath, error);
NSString *destinationName = nil;
if (success) {
NSMutableArray<NSString *> *existingEntries = nil;
const int listStatus = WMMReadAFCDirectory(&functions, afcConnection, collectionPath, &existingEntries);
if (listStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
listStatus,
[NSString stringWithFormat:@"Could not list the destination collection %@ (%d).", collectionPath, listStatus]
);
}
success = NO;
} else {
NSSet<NSString *> *existingNames = [NSSet setWithArray:existingEntries ?: @[]];
destinationName = preferredDestinationName;
NSUInteger suffix = 2;
while ([existingNames containsObject:destinationName]) {
destinationName = [NSString stringWithFormat:@"%@-%lu", preferredDestinationName, (unsigned long)suffix];
suffix += 1;
}
NSString *destinationPath = [collectionPath stringByAppendingPathComponent:destinationName];
const int renameStatus = functions.AFCRenamePath(
afcConnection,
stagedItemPath.fileSystemRepresentation,
destinationPath.fileSystemRepresentation
);
if (renameStatus != 0) {
if (error != NULL) {
*error = WMMMakeError(
renameStatus,
[NSString stringWithFormat:@"AFCRenamePath failed from %@ to %@ (%d).", stagedItemPath, destinationPath, renameStatus]
);
}
success = NO;
}
}
}
if (!success) {
WMMRemoveAFCTree(&functions, afcConnection, stagedItemPath);
destinationName = nil;
}
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
return destinationName;
}

View File

@ -18,26 +18,68 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
return .unavailable
return SourceAccessStatus(
availability: .unavailable,
mode: .unknown,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device source unavailable",
warningText: nil
)
}
let fallbackMode: SourceAccessMode = expectedDevice.connection == .usb ? .usbDevice : .networkDevice
do {
let devices = try await listConnectedDevices()
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
return .disconnected
return SourceAccessStatus(
availability: .disconnected,
mode: fallbackMode,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device disconnected",
warningText: nil
)
}
let mode: SourceAccessMode = device.connection == .usb ? .usbDevice : .networkDevice
let availability: SourceAvailability
let statusText: String?
switch device.trustState {
case .trusted:
return .available
availability = .available
statusText = nil
case .locked, .untrusted:
return .limited
availability = .limited
statusText = "Unlock and trust the device"
case .unavailable:
return .disconnected
availability = .disconnected
statusText = "Device unavailable"
}
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: device.name,
iconSystemName: "iphone.gen3",
statusText: statusText,
warningText: nil
)
} catch {
return .disconnected
return SourceAccessStatus(
availability: .disconnected,
mode: fallbackMode,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device status unavailable",
warningText: error.localizedDescription
)
}
}
@ -365,31 +407,6 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
guard case .connectedDevice(_, let container) = source.origin else {
throw SourceAccessError.accessFailed(reason: "The selected source is not a connected device.")
}
let minecraftRoot = container.minecraftFolderRelativePath?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !minecraftRoot.isEmpty else {
throw SourceAccessError.accessFailed(reason: "The connected device is missing its Minecraft content path.")
}
let destinationName = try await AppleMobileDeviceAccess.installDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
minecraftRootRelativePath: minecraftRoot,
collectionFolderName: payload.contentType.collectionFolderName,
preferredDestinationName: payload.suggestedFolderName,
sourceDirectoryURL: payload.preparedDirectoryURL
)
return InstalledContentItem(
contentType: payload.contentType,
displayName: payload.displayName,
destinationName: destinationName
)
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
guard source.origin.kind == .connectedDevice else {
return

View File

@ -10,7 +10,10 @@ enum SourceDiscoveryMode: Sendable {
protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult?
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error>
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities
nonisolated func discoverItems(
@ -18,6 +21,10 @@ protocol SourceAccessMethod: Sendable {
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error>
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
@ -25,7 +32,6 @@ 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
}
@ -34,6 +40,17 @@ extension SourceAccessMethod {
String(reflecting: Self.self)
}
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
_ = url
return nil
}
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
AsyncThrowingStream { continuation in
continuation.finish()
}
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier,
@ -43,8 +60,13 @@ extension SourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
_ = source
return .unknown
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
var status = source.origin.defaultAccessStatus(displayName: source.displayName)
status.availability = .unknown
return status
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -61,6 +83,64 @@ extension SourceAccessMethod {
_ = onDiscovered
}
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task.detached(priority: .userInitiated) {
let accessStatus = await accessStatus(for: source)
continuation.yield(.accessStatusChanged(accessStatus))
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: nil,
state: .running,
progress: .indeterminate
)
)
)
do {
try await discoverItems(for: source, mode: mode) { item in
continuation.yield(.discovered(item))
}
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: nil,
state: .succeeded,
progress: .indeterminate
)
)
)
continuation.finish()
} catch {
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: error.localizedDescription,
state: .failed,
progress: .indeterminate
)
)
)
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return item
@ -109,12 +189,6 @@ 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
}
@ -130,9 +204,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
nonisolated init(
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
javaLocalFolderAccess: SourceAccessMethod = JavaLocalFolderSourceAccess(),
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
) {
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess])
self.init(accessMethods: [localFolderAccess, javaLocalFolderAccess, connectedDeviceAccess])
}
nonisolated init(accessMethods: [any SourceAccessMethod]) {
@ -159,6 +234,64 @@ struct SourceAccessCoordinator: SourceAccessMethod {
fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).")
}
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
var bestProbe: SourceProbeResult?
for accessMethod in accessMethodsByIdentifier.values {
guard let probe = await accessMethod.probeLocalFolder(url) else {
continue
}
guard probe.confidence > .none else {
continue
}
if let currentBest = bestProbe {
if probe.confidence > currentBest.confidence {
bestProbe = probe
}
} else {
bestProbe = probe
}
}
return bestProbe
}
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
AsyncThrowingStream { continuation in
let accessMethods = Array(accessMethodsByIdentifier.values)
let task = Task.detached(priority: .userInitiated) {
await withTaskGroup(of: Void.self) { group in
for accessMethod in accessMethods {
group.addTask {
do {
for try await event in accessMethod.discoverSourceCandidates() {
continuation.yield(event)
}
} catch {
continuation.yield(
.warning(
ProviderWarning(
id: "\(accessMethod.accessorIdentifier)-candidate-discovery-failed",
message: "Source discovery failed",
detail: error.localizedDescription
)
)
)
}
}
}
}
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
nonisolated func discoverItems(
for source: MinecraftSource,
mode: SourceDiscoveryMode,
@ -171,6 +304,13 @@ struct SourceAccessCoordinator: SourceAccessMethod {
)
}
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error> {
accessMethod(for: source).scanEvents(for: source, mode: mode)
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
accessMethod(for: source).accessDescriptor(for: source)
}
@ -179,6 +319,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).availability(for: source)
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
return await accessMethod(for: source).accessStatus(for: source)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
return await accessMethod(for: source).capabilities(for: source)
}
@ -211,10 +355,6 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return try await accessMethod(for: source).materializeItem(for: item, in: source)
}
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
return try await accessMethod(for: source).install(payload, in: source)
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
await accessMethod(for: source).purgeCachedArtifacts(for: source)
}

View File

@ -3,11 +3,17 @@
import Foundation
struct LocalFolderSourceAccess: SourceAccessMethod {
typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess
struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
nonisolated init() {}
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
BedrockContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source
return SourceAccessDescriptor(
@ -18,9 +24,15 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
let candidateURL: URL
let mode: SourceAccessMode
if case .localFolder(let bookmarkData) = source.origin,
let bookmarkData {
mode = .securityScopedLocalFolder
var isStale = false
if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData,
@ -33,10 +45,19 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
candidateURL = source.folderURL
}
} else {
mode = .localFileSystem
candidateURL = source.folderURL
}
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: source.displayName,
iconSystemName: "folder",
statusText: availability == .available ? nil : "Folder unavailable",
warningText: nil
)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -133,97 +154,6 @@ 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,
@ -282,3 +212,185 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
return components.first.map(String.init)
}
}
struct JavaLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder"
private let candidateDiscoveryRoots: [URL]?
nonisolated init(candidateDiscoveryRoots: [URL]? = nil) {
self.candidateDiscoveryRoots = candidateDiscoveryRoots
}
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
}
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
AsyncThrowingStream { continuation in
let roots = candidateDiscoveryRoots
let providerID = accessorIdentifier
let task = Task.detached(priority: .utility) {
continuation.yield(
.stageUpdated(
WorkStage(
id: "\(providerID)-candidate-discovery",
title: "Finding Java sources",
detail: nil,
state: .running,
progress: .indeterminate
)
)
)
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: providerID,
searchRoots: roots
)
for candidate in candidates {
continuation.yield(.candidate(candidate))
}
continuation.yield(
.stageUpdated(
WorkStage(
id: "\(providerID)-candidate-discovery",
title: "Finding Java sources",
detail: candidates.isEmpty ? "No Java sources found." : "Found \(candidates.count) Java sources.",
state: .succeeded,
progress: .indeterminate
)
)
)
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source
return SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
let candidateURL: URL
let mode: SourceAccessMode
let bookmarkData: Data?
switch source.origin {
case .javaLocalFolder(let data), .localFolder(let data):
bookmarkData = data
case .connectedDevice:
bookmarkData = nil
}
if let bookmarkData {
mode = .securityScopedLocalFolder
var isStale = false
if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) {
candidateURL = resolvedURL.standardizedFileURL
} else {
candidateURL = source.folderURL
}
} else {
mode = .localFileSystem
candidateURL = source.folderURL
}
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: source.displayName,
iconSystemName: "folder",
statusText: availability == .available ? nil : "Folder unavailable",
warningText: nil
)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
_ = source
return .localFolder
}
nonisolated func discoverItems(
for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws {
_ = mode
let bookmarkData: Data?
switch source.origin {
case .javaLocalFolder(let data), .localFolder(let data):
bookmarkData = data
case .connectedDevice:
throw SourceAccessError.accessFailed(
reason: "No Java local-folder access method is configured for this source type."
)
}
let resolvedURL: URL
if let bookmarkData {
var isStale = false
guard let bookmarkURL = try? URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else {
throw SourceAccessError.accessFailed(
reason: "The saved folder bookmark could not be resolved."
)
}
resolvedURL = bookmarkURL.standardizedFileURL
} else {
resolvedURL = source.folderURL
}
let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
resolvedURL.stopAccessingSecurityScopedResource()
}
}
_ = try JavaContentScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return await JavaContentScanner.enrich(item: item)
}
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return JavaContentScanner.loadSize(for: item)
}
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
_ = source
let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey])
guard values?.isDirectory == true else {
return []
}
return try await BedrockLocalFolderSourceAccess().listItemContents(for: item, in: source)
}
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
_ = source
return item.folderURL
}
}

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
struct ConnectedDeviceDetailView: View {
let entry: ConnectedDeviceSidebarEntry
let addAction: (() -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(entry.device.name)
.font(.largeTitle.weight(.semibold))
Text("Available connected device")
.foregroundStyle(.secondary)
}
if let addAction {
Button("Add Source") {
addAction()
}
.buttonStyle(.borderedProminent)
}
sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Minecraft Access", rows: minecraftRows)
sourceSection(title: "Technical Details", rows: technicalRows)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(28)
}
}
private var overviewRows: [(String, String)] {
var rows: [(String, String)] = [
("Connection", connectionLabel),
("Trust State", trustStateLabel),
("Availability", entry.hasMinecraftContainer ? "Ready to add" : "Not ready")
]
if let productType = entry.device.productType, !productType.isEmpty {
rows.append(("Product Type", productType))
}
if let osVersion = entry.device.osVersion, !osVersion.isEmpty {
rows.append(("OS Version", osVersion))
}
return rows
}
private var minecraftRows: [(String, String)] {
if let error = entry.discoveryErrorDescription, !error.isEmpty {
return [("Discovery Error", error)]
}
guard let container = entry.minecraftContainer else {
return [("Minecraft Container", "Not found")]
}
var rows: [(String, String)] = [
("Minecraft Container", container.appName),
("App ID", container.appID),
("Access Mode", container.accessMode.rawValue)
]
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
rows.append(("Minecraft Path", relativePath))
}
return rows
}
private var technicalRows: [(String, String)] {
[
("UDID", entry.device.udid),
("Device ID", entry.id)
]
}
private var connectionLabel: String {
switch entry.device.connection {
case .usb:
return "USB"
case .network:
return "Network"
}
}
private var trustStateLabel: String {
switch entry.device.trustState {
case .trusted:
return "Trusted"
case .locked:
return "Locked"
case .untrusted:
return "Untrusted"
case .unavailable:
return "Unavailable"
}
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.appSectionTitleStyle(.section)
VStack(spacing: 0) {
ForEach(rows, id: \.0) { title, value in
detailRow(title: title, value: value)
}
}
.appDetailSectionCard()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.appTextStyle(.fieldLabel)
.frame(width: 150, alignment: .leading)
Text(value)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}

View File

@ -7,7 +7,8 @@ import SwiftUI
struct ItemDetailColumnView: View {
let item: MinecraftContentItem?
let source: MinecraftSource?
let installationState: SourceInstallationState?
let sourceCandidate: SourceCandidate?
let connectedDevice: ConnectedDeviceSidebarEntry?
let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference]
@ -23,12 +24,13 @@ struct ItemDetailColumnView: View {
let exportAction: () -> Void
let revealAction: () -> Void
let shareAction: (NSView?) -> Void
let importAction: (MinecraftSource) -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let revealCandidateAction: (SourceCandidate) -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
var body: some View {
Group {
if isEmpty {
} else if let item {
if let item {
ItemDetailView(
item: item,
source: source,
@ -47,13 +49,25 @@ struct ItemDetailColumnView: View {
shareAction: shareAction
)
} else if showsSourceDetails, let source {
SourceDetailView(
source: source,
installationState: installationState,
importAction: {
importAction(source)
SourceDetailView(source: source)
} else if let sourceCandidate {
SourceCandidateDetailView(
candidate: sourceCandidate,
addAction: {
addCandidateSourceAction(sourceCandidate)
},
revealAction: {
revealCandidateAction(sourceCandidate)
}
)
} else if let connectedDevice {
ConnectedDeviceDetailView(
entry: connectedDevice,
addAction: connectedDevice.hasMinecraftContainer ? {
addConnectedDeviceAction(connectedDevice)
} : nil
)
} else if isEmpty {
} else {
Text("Select a world or pack to see details")
.foregroundStyle(.secondary)

View File

@ -162,7 +162,7 @@ struct ItemDetailView: View {
recordSection(title: "Technical Details") {
VStack(alignment: .leading, spacing: 14) {
detailRow(title: "Folder ID", value: item.folderID)
detailRow(title: "Type", value: item.contentType.rawValue)
detailRow(title: "Type", value: item.platformType.displayName)
detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent)
if let spawn = item.worldMetadata?.spawn {
detailValueRow(title: "Spawn", value: spawn)
@ -249,13 +249,49 @@ struct ItemDetailView: View {
)
}
if item.contentType == .behaviorPack || item.contentType == .resourcePack {
if item.sourceEdition == .bedrock && (item.contentType == .behaviorPack || item.contentType == .resourcePack) {
detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable")
detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable")
if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion {
detailValueRow(title: "Minimum Engine", value: minimumEngineVersion)
}
}
if let javaPackMetadata {
if let description = javaPackMetadata.description {
detailRow(title: javaModMetadata == nil ? "Description" : "Pack Description", value: description)
}
if let packFormat = javaPackMetadata.packFormat {
detailValueRow(title: "Pack Format", value: String(packFormat))
}
if let supportedFormats = javaPackMetadata.supportedFormats {
detailValueRow(title: "Supported Formats", value: supportedFormats)
}
}
if let javaModMetadata {
if let modID = javaModMetadata.modID {
detailValueRow(title: "Mod ID", value: modID)
}
if let version = javaModMetadata.version {
detailValueRow(title: "Mod Version", value: version)
}
if let description = javaModMetadata.description {
detailRow(title: "Mod Description", value: description)
}
if !javaModMetadata.authors.isEmpty {
detailValueRow(title: "Authors", value: javaModMetadata.authors.joined(separator: ", "))
}
if let license = javaModMetadata.license {
detailValueRow(title: "License", value: license)
}
if let environment = javaModMetadata.environment {
detailValueRow(title: "Environment", value: environment)
}
if let minecraftRequirement = javaModMetadata.minecraftVersionRequirement {
detailValueRow(title: "Minecraft", value: minecraftRequirement)
}
}
}
}
@ -349,7 +385,13 @@ struct ItemDetailView: View {
}
private var heroMetadata: [String] {
var chips = [item.contentType.rawValue, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
var chips = [item.platformType.displayName, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
if let modID = javaModMetadata?.modID {
chips.append(modID)
} else if let packFormat = javaPackMetadata?.packFormat {
chips.append("Format \(packFormat)")
}
if item.contentType == .world {
let packCount = behaviorPacks.count + resourcePacks.count
@ -388,6 +430,22 @@ struct ItemDetailView: View {
return max(0, relatedWorldIDs.subtracting([item.id]).count)
}
private var javaPackMetadata: JavaPackMetadata? {
if case .java(let metadata) = item.platformMetadata {
return metadata.pack
}
return nil
}
private var javaModMetadata: JavaModMetadata? {
if case .java(let metadata) = item.platformMetadata {
return metadata.mod
}
return nil
}
private var actionRowExportTitle: String {
if exportTitle != nil {
switch item.contentType {

View File

@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
struct SourceCandidateDetailView: View {
let candidate: SourceCandidate
let addAction: () -> Void
let revealAction: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(candidate.displayName)
.font(.largeTitle.weight(.semibold))
Text("Found source candidate")
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button("Add Source") {
addAction()
}
.buttonStyle(.borderedProminent)
Button("Reveal in Finder") {
revealAction()
}
.buttonStyle(.bordered)
}
sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Detected Content", rows: contentRows)
sourceSection(title: "Location", rows: locationRows)
sourceSection(title: "Technical Details", rows: technicalRows)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(28)
}
}
private var overviewRows: [(String, String)] {
[
("Edition", editionLabel),
("Provider", providerLabel),
("Confidence", confidenceLabel),
("Reason", candidate.reason)
]
}
private var contentRows: [(String, String)] {
guard !candidate.detectedKinds.isEmpty else {
return [("Detected Kinds", "None")]
}
return [("Detected Kinds", orderedKindLabels.joined(separator: ", "))]
}
private var locationRows: [(String, String)] {
[
("Filesystem Path", candidate.sourceRootURL.path)
]
}
private var technicalRows: [(String, String)] {
[
("Provider ID", candidate.providerID),
("Candidate ID", candidate.id)
]
}
private var editionLabel: String {
switch candidate.edition {
case .bedrock:
return "Bedrock"
case .java:
return "Java"
}
}
private var providerLabel: String {
switch candidate.providerID {
case JavaLocalFolderSourceAccess().accessorIdentifier:
return "Java Local Folder"
case LocalFolderSourceAccess().accessorIdentifier:
return "Bedrock Local Folder"
case AppleMobileDeviceSourceAccess().accessorIdentifier:
return "Bedrock iOS Device"
default:
return candidate.providerID
}
}
private var confidenceLabel: String {
switch candidate.confidence {
case .none:
return "None"
case .weak:
return "Weak"
case .medium:
return "Medium"
case .strong:
return "Strong"
case .exact:
return "Exact"
}
}
private var orderedKindLabels: [String] {
let orderedKinds: [(MinecraftContentKind, String)] = [
(.world, "Worlds"),
(.behaviorPack, "Behavior Packs"),
(.resourcePack, "Resource Packs"),
(.dataPack, "Data Packs"),
(.skinPack, "Skin Packs"),
(.worldTemplate, "World Templates"),
(.shaderPack, "Shader Packs"),
(.mod, "Mods")
]
return orderedKinds.compactMap { kind, label in
candidate.detectedKinds.contains(kind) ? label : nil
}
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.appSectionTitleStyle(.section)
VStack(spacing: 0) {
ForEach(rows, id: \.0) { title, value in
detailRow(title: title, value: value)
}
}
.appDetailSectionCard()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.appTextStyle(.fieldLabel)
.frame(width: 150, alignment: .leading)
Text(value)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}

View File

@ -20,37 +20,12 @@ 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()
}
Text(source.displayName)
.font(.largeTitle.weight(.semibold))
if showsStatusSection {
sourceStatusSection
@ -208,7 +183,7 @@ struct SourceDetailView: View {
}
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
break
case .connectedDevice(let device, let container):
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
@ -222,19 +197,31 @@ struct SourceDetailView: View {
}
private var contentRows: [(String, String)] {
[
("Total Items", source.items.count.formatted(.number)),
("Worlds", itemCount(for: .world).formatted(.number)),
("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)),
("Resource Packs", itemCount(for: .resourcePack).formatted(.number)),
("Skin Packs", itemCount(for: .skinPack).formatted(.number)),
("World Templates", itemCount(for: .worldTemplate).formatted(.number))
var rows = [("Total Items", source.items.count.formatted(.number))]
let orderedKinds: [(MinecraftContentKind, String)] = [
(.world, "Worlds"),
(.behaviorPack, "Behavior Packs"),
(.resourcePack, "Resource Packs"),
(.dataPack, "Data Packs"),
(.skinPack, "Skin Packs"),
(.worldTemplate, "World Templates"),
(.shaderPack, "Shader Packs"),
(.mod, "Mods")
]
for (kind, title) in orderedKinds {
let count = itemCount(for: kind)
if count > 0 || source.edition == .bedrock && bedrockAlwaysDisplayedContentKinds.contains(kind) {
rows.append((title, count.formatted(.number)))
}
}
return rows
}
private var locationRows: [(String, String)] {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return [("Filesystem Path", source.folderURL.path)]
case .connectedDevice(_, let container):
var rows: [(String, String)] = [
@ -249,7 +236,7 @@ struct SourceDetailView: View {
private var technicalRows: [(String, String)] {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return []
case .connectedDevice(let device, let container):
var rows: [(String, String)] = [
@ -269,6 +256,8 @@ struct SourceDetailView: View {
switch source.origin {
case .localFolder:
return "Local Folder"
case .javaLocalFolder:
return "Java Local Folder"
case .connectedDevice:
return "Connected Device"
}
@ -508,6 +497,14 @@ struct SourceDetailView: View {
source.items.filter { $0.contentType == type }.count
}
private func itemCount(for kind: MinecraftContentKind) -> Int {
source.items.filter { $0.contentKind == kind }.count
}
private var bedrockAlwaysDisplayedContentKinds: Set<MinecraftContentKind> {
[.world, .behaviorPack, .resourcePack, .skinPack, .worldTemplate]
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {

View File

@ -41,7 +41,6 @@ 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 {
@ -56,14 +55,6 @@ 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)
}
@ -82,8 +73,8 @@ struct ItemListColumnView<MenuContent: View>: View {
sourceName: sourceName,
showsSourceName: showsSourceName,
title: title,
subtitle: subtitle,
showsSubtitle: showsSubtitle,
subtitle: navigationSubtitleText,
showsSubtitle: showsSubtitle || showsProjectionLoadingState,
isRefreshing: isRefreshing,
showsProjectionLoadingState: showsProjectionLoadingState
)
@ -91,7 +82,7 @@ struct ItemListColumnView<MenuContent: View>: View {
}
.searchable(text: $searchText, prompt: searchPrompt)
.navigationTitle(isEmpty ? "Library" : title)
.navigationSubtitle(isEmpty ? "" : subtitle)
.navigationSubtitle(isEmpty ? "" : navigationSubtitleText)
.toolbar {
if !isEmpty {
ToolbarItemGroup {
@ -109,6 +100,13 @@ struct ItemListColumnView<MenuContent: View>: View {
}
}
}
private var navigationSubtitleText: String {
if showsProjectionLoadingState {
return "Loading items..."
}
return subtitle
}
}
private struct ItemListHeaderView: View {
@ -139,8 +137,8 @@ private struct ItemListHeaderView: View {
}
}
if showsSubtitle || showsProjectionLoadingState {
Text(displaySubtitle)
if showsSubtitle {
Text(subtitle)
.appTextStyle(.supporting)
}
}
@ -150,14 +148,6 @@ private struct ItemListHeaderView: View {
.padding(.bottom, 12)
.appListHeaderSurface()
}
private var displaySubtitle: String {
if showsProjectionLoadingState {
return "Loading items..."
}
return subtitle
}
}
private struct ItemListLoadingOverlay: View {

View File

@ -6,7 +6,7 @@ import SwiftUI
#if DEBUG
enum PreviewFixtures {
nonisolated enum PreviewFixtures {
static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000)
static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1")
@ -132,7 +132,7 @@ enum PreviewFixtures {
)
static let primarySource: MinecraftSource = {
var source = MinecraftSource(folderURL: sourceOneURL)
var source = MinecraftSource(folderURL: sourceOneURL, availability: .available)
source.displayName = "Kid iPad Imports"
source.displayItems = [
featuredWorld,
@ -144,6 +144,9 @@ enum PreviewFixtures {
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.rawItems = source.displayItems
source.logicalPacks = [
LogicalPack(
@ -223,12 +226,15 @@ enum PreviewFixtures {
}()
static let secondarySource: MinecraftSource = {
var source = MinecraftSource(folderURL: sourceTwoURL)
var source = MinecraftSource(folderURL: sourceTwoURL, availability: .available)
source.displayName = "Downloads"
source.displayItems = [secondLibraryPack]
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.rawItems = source.displayItems
source.indexedItemCount = source.displayItems.count
source.indexedDetailCount = source.displayItems.count
@ -247,6 +253,87 @@ enum PreviewFixtures {
]
}
struct PreviewSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "preview-source"
nonisolated init() {}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
SourceAccessStatus(
availability: .available,
mode: .localFileSystem,
displayName: source.displayName,
iconSystemName: "folder",
statusText: nil,
warningText: nil
)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
_ = source
return .localFolder
}
nonisolated func discoverItems(
for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws {
_ = mode
for item in source.displayItems {
onDiscovered(item)
}
}
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
_ = item
_ = source
return PreviewFixtures.directoryEntries
}
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
_ = source
return item.folderURL
}
}
@MainActor
extension SourceLibrary {
static func makePreview() -> SourceLibrary {
let library = SourceLibrary(
sourceAccessMethod: PreviewSourceAccess(),
restoresPersistedSources: false,
startsBackgroundRefresh: false
)
library.sources = PreviewFixtures.allSources
library.sourceCandidates = [
SourceCandidate(
providerID: LocalFolderSourceAccess().accessorIdentifier,
edition: .bedrock,
sourceRootURL: URL(fileURLWithPath: "/tmp/preview-candidate"),
displayName: "Found Minecraft Folder",
confidence: .strong,
reason: "Contains Minecraft content folders",
detectedKinds: [.world, .resourcePack]
)
]
return library
}
}
extension ContentViewDependencies {
@MainActor
static func makePreview() -> ContentViewDependencies {
let connectedDeviceAccess = AppleMobileDeviceSourceAccess()
return ContentViewDependencies(
library: .makePreview(),
connectedDeviceAccess: connectedDeviceAccess,
deviceSourceFactory: ConnectedDeviceSourceFactory(),
itemActionService: ContentItemActionService()
)
}
}
@MainActor
struct SidebarColumnPreviewContainer: View {
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
@ -256,15 +343,16 @@ struct SidebarColumnPreviewContainer: View {
SourcesSidebarView(
sources: PreviewFixtures.allSources,
connectedDevices: [],
sourceCandidates: [],
isDiscoveringSourceCandidates: false,
selection: $selection,
addSourceAction: {},
discoverSourcesAction: {},
addCandidateSourceAction: { _ in },
addDeviceSourceAction: {},
addConnectedDeviceAction: { _ in },
rescanSourceAction: { _ in },
removeSourceAction: { _ in },
importSourceAction: { _ in },
importDropAction: { _, _ in false },
installationState: { _ in nil },
filters: { source in
let allFilter = SidebarFilter(
title: "All Content",
@ -334,7 +422,6 @@ struct ItemListColumnPreviewContainer: View {
searchPrompt: "Search Worlds",
chooseFolderAction: {},
dropAction: { _ in false },
dragProvider: { _ in NSItemProvider() },
itemContextMenu: { item in
Button("Reveal \(item.displayName)") {}
}
@ -349,7 +436,8 @@ struct ItemDetailColumnPreviewContainer: View {
ItemDetailColumnView(
item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource,
installationState: nil,
sourceCandidate: nil,
connectedDevice: nil,
showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -365,7 +453,9 @@ struct ItemDetailColumnPreviewContainer: View {
exportAction: {},
revealAction: {},
shareAction: { _ in },
importAction: { _ in }
addCandidateSourceAction: { _ in },
revealCandidateAction: { _ in },
addConnectedDeviceAction: { _ in }
)
}
}

View File

@ -14,7 +14,6 @@ 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
@ -33,19 +32,26 @@ struct ContentView: View {
private let directoryPreviewLimit = 12
private let projectionLoadingDelay: Duration = .milliseconds(150)
init() {
let dependencies = ContentViewDependencies.makeDefault()
init(
dependencies: ContentViewDependencies = ContentViewDependencies.makeDefault(),
initialSidebarSelection: SidebarSelection? = nil,
initialItemID: MinecraftContentItem.ID? = nil
) {
self.connectedDeviceAccess = dependencies.connectedDeviceAccess
self.deviceSourceFactory = dependencies.deviceSourceFactory
self.itemActionService = dependencies.itemActionService
_library = StateObject(
wrappedValue: dependencies.library
)
_selectedSidebarSelection = State(initialValue: initialSidebarSelection)
_selectedItemID = State(initialValue: initialItemID)
}
var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
let isEmptyLibrary = library.visibleSources.isEmpty && library.sidebarConnectedDevices.isEmpty && library.sourceCandidates.isEmpty
let resolvedCurrentSource = currentSource
let resolvedCurrentSourceCandidate = currentSourceCandidate
let resolvedCurrentConnectedDevice = currentConnectedDevice
let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection,
searchText: searchText,
@ -75,9 +81,15 @@ struct ContentView: View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView(
sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
connectedDevices: library.sidebarConnectedDevices,
sourceCandidates: library.sourceCandidates,
isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates,
selection: sidebarSelectionBinding,
addSourceAction: pickFolder,
discoverSourcesAction: {
library.perform(.discoverSourceCandidates)
},
addCandidateSourceAction: addCandidateSource(_:),
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:),
rescanSourceAction: { source in
@ -88,11 +100,6 @@ 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)
@ -115,7 +122,6 @@ struct ContentView: View {
searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:),
dragProvider: dragProvider(for:),
itemContextMenu: itemContextMenu(for:)
)
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
@ -123,7 +129,8 @@ struct ContentView: View {
ItemDetailColumnView(
item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource,
installationState: resolvedCurrentSource.flatMap { library.installationStateBySourceID[$0.id] },
sourceCandidate: resolvedCurrentSourceCandidate,
connectedDevice: resolvedCurrentConnectedDevice,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -157,7 +164,9 @@ struct ContentView: View {
shareItem(item, from: anchorView)
},
importAction: importIntoSource(_:)
addCandidateSourceAction: addCandidateSource(_:),
revealCandidateAction: revealCandidateInFinder(_:),
addConnectedDeviceAction: addConnectedDeviceSource(from:)
)
.frame(minWidth: 450)
}
@ -178,23 +187,6 @@ struct ContentView: View {
}
)
}
.alert(
"Import Failed",
isPresented: Binding(
get: { importErrorMessage != nil },
set: { isPresented in
if !isPresented {
importErrorMessage = nil
}
}
)
) {
Button("OK") {
importErrorMessage = nil
}
} message: {
Text(importErrorMessage ?? "The Minecraft content could not be imported.")
}
.task {
AppTerminationCoordinator.shared.register(library: library)
}
@ -209,7 +201,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, _ in
syncSelection(with: library.visibleSources.map(\.id))
}
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
.onChange(of: library.sidebarConnectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
syncSelection(with: library.visibleSources.map(\.id))
}
.task(id: currentProjectionRequest) {
@ -275,6 +267,22 @@ struct ContentView: View {
return library.source(withID: sourceID)
}
private var currentSourceCandidate: SourceCandidate? {
guard case .sourceCandidate(let candidateID) = selectedSidebarSelection else {
return nil
}
return library.sourceCandidates.first { $0.id == candidateID }
}
private var currentConnectedDevice: ConnectedDeviceSidebarEntry? {
guard case .connectedDevice(let deviceID) = selectedSidebarSelection else {
return nil
}
return library.sidebarConnectedDevices.first { $0.id == deviceID }
}
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else {
return nil
@ -357,16 +365,27 @@ struct ContentView: View {
}
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
return MinecraftContentType.allCases.compactMap { contentType in
guard let count = source.displayItemCountsByType[contentType], count > 0 else {
let orderedKinds: [MinecraftContentKind] = [
.world,
.behaviorPack,
.resourcePack,
.dataPack,
.skinPack,
.worldTemplate,
.shaderPack,
.mod
]
return orderedKinds.compactMap { contentKind in
guard let count = source.displayItemCountsByKind[contentKind], count > 0 else {
return nil
}
return SidebarFilter(
title: sidebarTitle(for: contentType),
iconName: sidebarIcon(for: contentType),
title: sidebarTitle(for: contentKind),
iconName: sidebarIcon(for: contentKind),
count: count,
selection: .contentType(sourceID: source.id, contentType: contentType)
selection: .contentKind(sourceID: source.id, contentKind: contentKind)
)
}
}
@ -398,6 +417,27 @@ struct ContentView: View {
}
}
private func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .dataPack:
return "Data Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
case .shaderPack:
return "Shader Packs"
case .mod:
return "Mods"
}
}
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
@ -413,6 +453,27 @@ struct ContentView: View {
}
}
private func sidebarIcon(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .dataPack:
return "curlybraces.square"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "map"
case .shaderPack:
return "camera.filters"
case .mod:
return "hammer"
}
}
@ViewBuilder
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
Button("Share...") {
@ -547,169 +608,23 @@ struct ContentView: View {
}
for url in panel.urls {
let sourceID = library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
}
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)
Task { @MainActor in
let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
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)
private func addCandidateSource(_ candidate: SourceCandidate) {
Task {
let sourceID = await library.addSource(candidate: candidate)
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 revealCandidateInFinder(_ candidate: SourceCandidate) {
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
@ -729,7 +644,7 @@ struct ContentView: View {
}
Task { @MainActor in
let sourceID = library.addSource(at: url)
let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
}
@ -771,8 +686,22 @@ struct ContentView: View {
}
private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
if let selectedSidebarSelection {
switch selectedSidebarSelection {
case .sourceCandidate(let candidateID):
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .connectedDevice(let deviceID):
if !library.sidebarConnectedDevices.contains(where: { $0.id == deviceID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .source, .allContent, .contentType, .contentKind:
if let selectedSourceID = selectedSidebarSelection.sourceID,
!sourceIDs.contains(selectedSourceID) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
}
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
}
@ -953,7 +882,7 @@ struct ContentView: View {
}
private func archiveType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
itemActionService.archiveContentType(for: item)
}
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {
@ -995,8 +924,19 @@ struct ContentView: View {
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
ContentView(
dependencies: .makePreview(),
initialSidebarSelection: .contentKind(
sourceID: PreviewFixtures.primarySource.id,
contentKind: .world
),
initialItemID: PreviewFixtures.featuredWorld.id
)
.frame(width: 1_440, height: 900)
.previewDisplayName("Full Window")
}
}
#endif

View File

@ -77,21 +77,31 @@ enum ItemCollectionProjector {
}
switch selection {
case .sourceCandidate:
return "Source Candidate"
case .connectedDevice:
return "Connected Device"
case .source, .allContent:
return "All Items"
case .contentType(_, let contentType):
return sidebarTitle(for: contentType)
case .contentKind(_, let contentKind):
return sidebarTitle(for: contentKind)
}
}
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection {
case .some(.sourceCandidate), .some(.connectedDevice):
return "Search Library"
case .some(.source):
return "Search \(source?.displayName ?? "Library")"
case .some(.allContent):
return "Search All Items"
case .some(.contentType(_, let contentType)):
return "Search \(sidebarTitle(for: contentType))"
case .some(.contentKind(_, let contentKind)):
return "Search \(sidebarTitle(for: contentKind))"
case .none:
return "Search Library"
}
@ -99,12 +109,18 @@ enum ItemCollectionProjector {
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
switch selection {
case .some(.sourceCandidate):
return "Source Candidate"
case .some(.connectedDevice):
return "Connected Device"
case .some(.source):
return "Library"
case .some(.allContent):
return "All"
case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType)
case .some(.contentKind(_, let contentKind)):
return sidebarTitle(for: contentKind)
case .none:
return "Library"
}
@ -116,6 +132,8 @@ enum ItemCollectionProjector {
}
switch selection {
case .sourceCandidate, .connectedDevice:
return "items"
case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items"
case .contentType(_, let contentType):
@ -125,6 +143,17 @@ enum ItemCollectionProjector {
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs"
}
case .contentKind(_, let contentKind):
switch contentKind {
case .world:
return scopedItemCount == 1 ? "world" : "worlds"
case .mod:
return scopedItemCount == 1 ? "mod" : "mods"
case .shaderPack:
return scopedItemCount == 1 ? "shader pack" : "shader packs"
case .behaviorPack, .resourcePack, .dataPack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs"
}
}
}
@ -143,6 +172,27 @@ enum ItemCollectionProjector {
}
}
nonisolated private static func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .dataPack:
return "Data Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
case .shaderPack:
return "Shader Packs"
case .mod:
return "Mods"
}
}
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
}

View File

@ -160,6 +160,7 @@ private struct AppTransportBadgeBubbleModifier: ViewModifier {
enum AppCapsuleLabelStyle {
case sidebarSubtle
case sidebarAccent
case sidebarSelected
case heroMetadata
}
@ -181,6 +182,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
return AnyShapeStyle(.secondary)
case .sidebarAccent:
return AnyShapeStyle(Color.appAccent)
case .sidebarSelected:
return AnyShapeStyle(.white.opacity(0.92))
case .heroMetadata:
return AnyShapeStyle(.white.opacity(0.95))
}
@ -192,6 +195,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
return AnyShapeStyle(.secondary.opacity(0.12))
case .sidebarAccent:
return AnyShapeStyle(Color.appAccent.opacity(0.14))
case .sidebarSelected:
return AnyShapeStyle(.white.opacity(0.16))
case .heroMetadata:
return AnyShapeStyle(.white.opacity(0.14))
}
@ -201,7 +206,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
switch style {
case .heroMetadata:
return 10
case .sidebarSubtle, .sidebarAccent:
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
return 7
}
}
@ -210,7 +215,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
switch style {
case .heroMetadata:
return 7
case .sidebarSubtle, .sidebarAccent:
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
return 4
}
}

View File

@ -2,17 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
import UniformTypeIdentifiers
enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case connectedDevice(deviceID: String)
case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL {
var sourceID: URL? {
switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _):
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID
case .sourceCandidate, .connectedDevice:
return nil
}
}
}
@ -25,44 +29,80 @@ struct SidebarFilter: Identifiable, Hashable {
let selection: SidebarSelection
}
private struct SidebarNode: Identifiable, Hashable {
let id: SidebarSelection
let row: SidebarNodeRow
let children: [SidebarNode]?
var selection: SidebarSelection { id }
}
private enum SidebarNodeRow: Hashable {
case source(MinecraftSource)
case filter(SidebarFilter)
case connectedDevice(ConnectedDeviceSidebarEntry)
case sourceCandidate(SourceCandidate)
}
struct SourcesSidebarView: View {
let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry]
let sourceCandidates: [SourceCandidate]
let isDiscoveringSourceCandidates: Bool
@Binding var selection: SidebarSelection?
let addSourceAction: () -> Void
let discoverSourcesAction: () -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let addDeviceSourceAction: () -> Void
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 {
List(selection: $selection) {
if !sources.isEmpty {
if !libraryNodes.isEmpty {
Section {
ForEach(sources) { source in
sourceSectionRows(for: source)
}
OutlineGroup(libraryNodes, children: \.children, content: sidebarNodeRow)
} header: {
SidebarSourcesSectionHeaderView(title: "Libraries")
}
}
if !connectedDevices.isEmpty {
if !deviceNodes.isEmpty {
Section {
ForEach(connectedDevices) { entry in
connectedDeviceSectionRows(for: entry)
}
OutlineGroup(deviceNodes, children: \.children, content: sidebarNodeRow)
} header: {
SidebarSourcesSectionHeaderView(title: "Available Devices")
}
}
if !candidateNodes.isEmpty {
Section {
OutlineGroup(candidateNodes, children: \.children, content: sidebarNodeRow)
} header: {
SidebarSourcesSectionHeaderView(title: "Found Sources")
}
}
}
.listStyle(.sidebar)
.transaction { transaction in
transaction.animation = nil
}
.toolbar {
ToolbarItem {
Button(action: discoverSourcesAction) {
if isDiscoveringSourceCandidates {
ProgressView()
.appActivityIndicatorStyle(.small)
} else {
Image(systemName: "magnifyingglass")
}
}
.disabled(isDiscoveringSourceCandidates)
.help("Find Minecraft Sources")
}
ToolbarItem {
Button(action: addSourceAction) {
Image(systemName: "folder.badge.plus")
@ -79,77 +119,151 @@ struct SourcesSidebarView: View {
}
}
@ViewBuilder
private func sourceSectionRows(for source: MinecraftSource) -> some View {
let sourceFilters = filters(source)
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?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source)
}
Button("Import into \"\(source.displayName)\"...") {
importSourceAction(source)
}
.disabled(source.availability != .available || !source.capabilities.canInstallItems)
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
private var libraryNodes: [SidebarNode] {
sources.map { source in
let childNodes = filters(source).map { filter in
SidebarNode(
id: filter.selection,
row: .filter(filter),
children: nil
)
}
ForEach(sourceFilters) { filter in
SidebarFilterRow(filter: filter, isIndented: true)
.tag(filter.selection as SidebarSelection?)
return SidebarNode(
id: .source(sourceID: source.id),
row: .source(source),
children: childNodes.isEmpty ? nil : childNodes
)
}
}
private var deviceNodes: [SidebarNode] {
connectedDevices.map { entry in
SidebarNode(
id: .connectedDevice(deviceID: entry.id),
row: .connectedDevice(entry),
children: nil
)
}
}
private var candidateNodes: [SidebarNode] {
sourceCandidates.map { candidate in
SidebarNode(
id: .sourceCandidate(candidateID: candidate.id),
row: .sourceCandidate(candidate),
children: nil
)
}
}
@ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
ConnectedDeviceRow(
entry: entry,
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
switch node.row {
case .source(let source):
SourceHeaderRow(source: source, isSelected: selection == node.selection)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source)
}
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
}
case .filter(let filter):
SidebarFilterRow(filter: filter)
.tag(node.selection as SidebarSelection?)
case .connectedDevice(let entry):
ConnectedDeviceRow(
entry: entry,
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
case .sourceCandidate(let candidate):
SourceCandidateRow(
candidate: candidate,
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
}
}
}
private struct SourceCandidateRow: View {
let candidate: SourceCandidate
let addAction: () -> Void
var body: some View {
HStack(spacing: 8) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(candidate.displayName)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} icon: {
Image(systemName: symbolName)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Button(action: addAction) {
Text("Add")
}
.appMiniProminentButton()
.help("Add Source")
}
.padding(.vertical, 4)
}
private var symbolName: String {
switch candidate.edition {
case .bedrock:
return "folder"
case .java:
return "curlybraces"
}
}
private var subtitle: String {
let editionName = candidate.edition == .java ? "Java" : "Bedrock"
return "\(editionName) - \(candidate.sourceRootURL.lastPathComponent)"
}
}
private struct SidebarFilterRow: View {
let filter: SidebarFilter
let isIndented: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: filter.iconName)
.frame(width: 16)
.foregroundStyle(.secondary)
Text(filter.title)
HStack {
Label {
Text(filter.title)
} icon: {
Image(systemName: filter.iconName)
.foregroundStyle(.secondary)
}
Spacer()
Text(filter.count, format: .number)
.foregroundStyle(.secondary)
}
.padding(.leading, isIndented ? 16 : 0)
}
}
@ -163,28 +277,31 @@ 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
let isSelected: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: headerSymbolName)
.foregroundStyle(.secondary)
Text(source.displayName)
.lineLimit(1)
HStack {
Label {
Text(source.displayName)
.lineLimit(1)
} icon: {
Image(systemName: headerSymbolName)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
HStack(spacing: 8) {
if let availabilityBadgeText {
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
SourceAvailabilityBadge(
text: availabilityBadgeText,
emphasis: availabilityBadgeEmphasis,
isSelected: isSelected
)
}
if let connection {
SourceConnectionBadge(connection: connection)
SourceConnectionBadge(connection: connection, isSelected: isSelected)
}
if showsStatusAccessory {
@ -193,25 +310,6 @@ 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? {
@ -228,7 +326,7 @@ private struct SourceHeaderRow: View {
private var headerSymbolName: String {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return "folder"
case .connectedDevice:
return "iphone.gen3"
@ -255,39 +353,29 @@ private struct SourceHeaderRow: View {
}
private var showsStatusAccessory: Bool {
source.isScanning || installationState != nil
source.isScanning
}
@ViewBuilder
private var statusAccessory: some View {
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 {
ProgressView()
.appActivityIndicatorStyle(.small)
}
if source.isScanning {
if let scanProgress = source.scanProgress {
CircularScanProgressView(progress: scanProgress, isSelected: isSelected)
} else {
ProgressView()
.appActivityIndicatorStyle(.small)
}
}
}
}
private struct SourceConnectionBadge: View {
let connection: DeviceConnection
let isSelected: Bool
var body: some View {
Image(systemName: symbolName)
.appCapsuleLabelStyle(.sidebarSubtle)
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : .sidebarSubtle)
.help(helpText)
.accessibilityLabel(helpText)
}
@ -314,30 +402,35 @@ private struct SourceConnectionBadge: View {
private struct SourceAvailabilityBadge: View {
let text: String
let emphasis: Bool
let isSelected: Bool
var body: some View {
Text(text)
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : emphasis ? .sidebarAccent : .sidebarSubtle)
}
}
private struct CircularScanProgressView: View {
let progress: Double
let isSelected: Bool
private let size: CGFloat = 17
private let lineWidth: CGFloat = 1.4
var body: some View {
ZStack {
Circle()
.stroke(.secondary.opacity(0.18), lineWidth: 3)
.stroke(isSelected ? .white.opacity(0.18) : Color.secondary.opacity(0.24), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: max(0.02, min(progress, 1)))
.trim(from: 0, to: max(0, min(progress, 1)))
.stroke(
Color.appAccent,
style: StrokeStyle(lineWidth: 3, lineCap: .round)
isSelected ? .white.opacity(0.86) : Color.appAccent,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.frame(width: 18, height: 18)
.frame(width: size, height: size)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Scan progress")
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
@ -349,20 +442,22 @@ private struct ConnectedDeviceRow: View {
let addAction: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 10) {
ConnectedDeviceTransportIcon(
baseSymbolName: iconName,
connection: entry.device.connection,
tint: iconColor
)
HStack(alignment: .top) {
Label {
VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name)
.appTextStyle(.rowTitle)
.foregroundStyle(titleColor)
VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name)
.appTextStyle(.rowTitle)
.foregroundStyle(titleColor)
Text(statusText)
.appTextStyle(.supportingCompact)
Text(statusText)
.appTextStyle(.supportingCompact)
}
} icon: {
ConnectedDeviceTransportIcon(
baseSymbolName: iconName,
connection: entry.device.connection,
tint: iconColor
)
}
Spacer(minLength: 12)

View File

@ -13,6 +13,9 @@ struct World_Manager_for_MinecraftTests {
@Test func sourceOriginsExposeOutboundCapabilities() async throws {
let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local"))
#expect(localSource.capabilities == .localFolder)
#expect(localSource.edition == .bedrock)
#expect(localSource.providerID == LocalFolderSourceAccess().accessorIdentifier)
#expect(localSource.accessStatus.mode == .localFileSystem)
let device = ConnectedDevice(
udid: "device",
@ -35,6 +38,556 @@ struct World_Manager_for_MinecraftTests {
)
#expect(deviceSource.capabilities == .connectedDevice)
#expect(deviceSource.edition == .bedrock)
#expect(deviceSource.providerID == AppleMobileDeviceSourceAccess().accessorIdentifier)
#expect(deviceSource.accessStatus.mode == .usbDevice)
}
@Test func contentItemsExposeNeutralProviderSurface() async throws {
let rootURL = URL(fileURLWithPath: "/tmp/source")
let item = MinecraftContentItem(
folderURL: rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true),
folderName: "WorldA",
contentType: .world,
collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true),
displayName: "World A",
packUUID: "ABC-123",
packVersion: "1.0.0"
)
#expect(item.sourceEdition == .bedrock)
#expect(item.contentKind == .world)
#expect(item.platformType == .bedrock(.world))
#expect(item.capabilities.portablePackageExtension == "mcworld")
if case .bedrock(let metadata) = item.platformMetadata {
#expect(metadata.packUUID == "abc-123")
#expect(metadata.packVersion == "1.0.0")
} else {
Issue.record("Expected Bedrock metadata")
}
}
@Test func bedrockCompatibilityFieldsSynchronizePlatformMetadata() async throws {
let rootURL = URL(fileURLWithPath: "/tmp/source")
var item = MinecraftContentItem(
folderURL: rootURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true),
folderName: "PackA",
contentType: .behaviorPack,
collectionRootURL: rootURL.appendingPathComponent("behavior_packs", isDirectory: true)
)
item.packUUID = "PACK-A"
item.packVersion = "2.0.0"
if case .bedrock(let metadata) = item.platformMetadata {
#expect(metadata.packUUID == "pack-a")
#expect(metadata.packVersion == "2.0.0")
} else {
Issue.record("Expected Bedrock metadata")
}
}
@Test func localFolderAccessStreamsProviderEvents() async throws {
let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true)
defer { try? fileManager.removeItem(at: rootURL) }
try fileManager.createDirectory(at: itemURL, withIntermediateDirectories: true)
try "World A".write(
to: itemURL.appendingPathComponent("levelname.txt"),
atomically: true,
encoding: .utf8
)
let source = MinecraftSource(folderURL: rootURL)
let access = LocalFolderSourceAccess()
var sawAccessStatus = false
var sawRunningStage = false
var sawFinishedStage = false
var discoveredItems: [MinecraftContentItem] = []
for try await event in access.scanEvents(for: source, mode: .fullScan) {
switch event {
case .accessStatusChanged(let status):
sawAccessStatus = true
#expect(status.availability == .available)
#expect(status.mode == .localFileSystem)
case .stageUpdated(let stage):
if stage.state == .running {
sawRunningStage = true
}
if stage.state == .succeeded {
sawFinishedStage = true
}
case .discovered(let item):
discoveredItems.append(item)
case .inspected, .warning:
break
}
}
#expect(sawAccessStatus)
#expect(sawRunningStage)
#expect(sawFinishedStage)
#expect(discoveredItems.map(\.displayName).contains("WorldA"))
}
@Test func javaLocalFolderSourceUsesJavaProviderDefaults() async throws {
let source = MinecraftSource(
folderURL: URL(fileURLWithPath: "/tmp/java"),
origin: .javaLocalFolder(bookmarkData: nil)
)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessStatus.mode == .localFileSystem)
}
@Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws {
let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let instanceURL = rootURL.appendingPathComponent("Better MC [NEOFORGE] BMC5", isDirectory: true)
let worldURL = instanceURL.appendingPathComponent("saves/JavaWorld", isDirectory: true)
let packURL = instanceURL.appendingPathComponent("resourcepacks/JavaPack", isDirectory: true)
let zippedPackURL = instanceURL.appendingPathComponent("resourcepacks/JavaPack.zip")
let shaderPackURL = instanceURL.appendingPathComponent("shaderpacks/Shader.zip")
let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar")
defer { try? fileManager.removeItem(at: rootURL) }
try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true)
try Data().write(to: worldURL.appendingPathComponent("level.dat"))
try "Displayed Java World".write(
to: worldURL.appendingPathComponent("levelname.txt"),
atomically: true,
encoding: .utf8
)
try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true)
try "{}".write(
to: packURL.appendingPathComponent("pack.mcmeta"),
atomically: true,
encoding: .utf8
)
try fileManager.createDirectory(at: zippedPackURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("zip".utf8).write(to: zippedPackURL)
try fileManager.createDirectory(at: shaderPackURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("shader".utf8).write(to: shaderPackURL)
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("jar".utf8).write(to: modURL)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
let probe = await access.probeLocalFolder(rootURL)
#expect(probe?.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(probe?.sourceRootURL == instanceURL.standardizedFileURL)
#expect(probe?.detectedKinds.contains(.mod) == true)
var source = MinecraftSource(
folderURL: instanceURL,
origin: .localFolder(bookmarkData: nil),
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
)
source.edition = .java
source.providerID = JavaLocalFolderSourceAccess().accessorIdentifier
var discoveredItems: [MinecraftContentItem] = []
for try await event in access.scanEvents(for: source, mode: .fullScan) {
if case .discovered(let item) = event {
discoveredItems.append(item)
}
}
var enrichedItems: [MinecraftContentItem] = []
for item in discoveredItems {
enrichedItems.append(await access.enrich(item, for: source))
}
#expect(discoveredItems.count == 5)
#expect(discoveredItems.allSatisfy { $0.sourceEdition == .java })
#expect(discoveredItems.contains { $0.platformType == .java(.world) && $0.capabilities.portablePackageExtension == "zip" })
#expect(discoveredItems.contains { $0.platformType == .java(.resourcePack) && $0.contentType == .resourcePack })
#expect(discoveredItems.contains { $0.platformType == .java(.shaderPack) && $0.contentKind == .shaderPack })
#expect(discoveredItems.contains { $0.platformType == .java(.mod) && $0.contentKind == .mod })
#expect(enrichedItems.contains { $0.displayName == "Displayed Java World" })
var indexedSource = source
indexedSource.rawItems = enrichedItems
let index = SourceContentIndexer.buildIndex(for: indexedSource)
#expect(index.displayItemCountsByKind[.world] == 1)
#expect(index.displayItemCountsByKind[.resourcePack] == 2)
#expect(index.displayItemCountsByKind[.shaderPack] == 1)
#expect(index.displayItemCountsByKind[.mod] == 1)
indexedSource.rawItems = enrichedItems
let snapshot = SourceScanPolicy.buildSnapshot(for: indexedSource, scanRootURL: instanceURL)
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("saves"))
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("resourcepacks"))
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("shaderpacks"))
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("mods"))
}
@Test func javaArchiveEnrichmentReadsModMetadataPackMetadataAndIcons() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let modSourceURL = workingURL.appendingPathComponent("ModSource", isDirectory: true)
let resourceSourceURL = workingURL.appendingPathComponent("ResourceSource", isDirectory: true)
let modArchiveURL = workingURL.appendingPathComponent("ExampleMod.jar")
let resourceArchiveURL = workingURL.appendingPathComponent("ExamplePack.zip")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: modSourceURL.appendingPathComponent("META-INF", isDirectory: true), withIntermediateDirectories: true)
try """
modLoader = "javafml"
loaderVersion = "[1,)"
[[mods]]
modId = "examplemod"
version = "1.2.3"
displayName = "Example Java Mod"
logoFile = "icon.png"
authors = "Alex, Sam"
description = "A test mod."
[[dependencies.examplemod]]
modId = "minecraft"
versionRange = "[1.21,)"
""".write(
to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"),
atomically: true,
encoding: .utf8
)
try """
{
"pack": {
"description": "Example Mod Resources",
"pack_format": 31
}
}
""".write(to: modSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8)
try Data([0x89, 0x50, 0x4E, 0x47]).write(to: modSourceURL.appendingPathComponent("icon.png"))
try makeArchive(from: modSourceURL, to: modArchiveURL)
try fileManager.createDirectory(at: resourceSourceURL, withIntermediateDirectories: true)
try """
{
"pack": {
"description": "Example Resource Pack",
"pack_format": 34,
"supported_formats": {
"min_inclusive": 34,
"max_inclusive": 42
}
}
}
""".write(to: resourceSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8)
try Data([0x89, 0x50, 0x4E, 0x47]).write(to: resourceSourceURL.appendingPathComponent("pack.png"))
try makeArchive(from: resourceSourceURL, to: resourceArchiveURL)
let modItem = MinecraftContentItem(
folderURL: modArchiveURL,
folderName: modArchiveURL.lastPathComponent,
contentType: .resourcePack,
sourceEdition: .java,
contentKind: .mod,
platformType: .java(.mod),
collectionRootURL: workingURL,
capabilities: .java(contentType: .mod),
platformMetadata: .java(JavaContentMetadata())
)
let resourceItem = MinecraftContentItem(
folderURL: resourceArchiveURL,
folderName: resourceArchiveURL.lastPathComponent,
contentType: .resourcePack,
sourceEdition: .java,
contentKind: .resourcePack,
platformType: .java(.resourcePack),
collectionRootURL: workingURL,
capabilities: .java(contentType: .resourcePack),
platformMetadata: .java(JavaContentMetadata())
)
let enrichedMod = await JavaContentScanner.enrich(item: modItem)
let enrichedResource = await JavaContentScanner.enrich(item: resourceItem)
#expect(enrichedMod.displayName == "Example Java Mod")
#expect(enrichedMod.iconURL != nil)
#expect(enrichedMod.hasKnownIcon)
if case .java(let metadata) = enrichedMod.platformMetadata {
#expect(metadata.pack?.description == "Example Mod Resources")
#expect(metadata.pack?.packFormat == 31)
#expect(metadata.mod?.modID == "examplemod")
#expect(metadata.mod?.version == "1.2.3")
#expect(metadata.mod?.description == "A test mod.")
#expect(metadata.mod?.authors == ["Alex", "Sam"])
#expect(metadata.mod?.minecraftVersionRequirement == "[1.21,)")
} else {
Issue.record("Expected Java metadata")
}
#expect(enrichedResource.iconURL != nil)
if case .java(let metadata) = enrichedResource.platformMetadata {
#expect(metadata.pack?.description == "Example Resource Pack")
#expect(metadata.pack?.packFormat == 34)
#expect(metadata.pack?.supportedFormats == "34-42")
} else {
Issue.record("Expected Java metadata")
}
}
@Test func javaProviderDiscoversSourceCandidatesFromBoundedRoots() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let instanceRootURL = workingURL
.appendingPathComponent("PrismLauncher/instances/Example Instance/.minecraft", isDirectory: true)
let modURL = instanceRootURL.appendingPathComponent("mods/ExampleMod.jar")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("jar".utf8).write(to: modURL)
try fileManager.createDirectory(
at: instanceRootURL.appendingPathComponent("resourcepacks", isDirectory: true),
withIntermediateDirectories: true
)
let access = JavaLocalFolderSourceAccess(candidateDiscoveryRoots: [workingURL])
var candidates: [SourceCandidate] = []
for try await event in access.discoverSourceCandidates() {
if case .candidate(let candidate) = event {
candidates.append(candidate)
}
}
#expect(candidates.contains { candidate in
candidate.providerID == JavaLocalFolderSourceAccess().accessorIdentifier
&& candidate.edition == .java
&& candidate.sourceRootURL == instanceRootURL.standardizedFileURL
&& candidate.detectedKinds.contains(.mod)
&& candidate.detectedKinds.contains(.resourcePack)
})
}
@Test func javaProviderCollapsesNestedSourceCandidatesToSearchRoot() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
for instanceURL in [firstInstanceURL, secondInstanceURL] {
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
}
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
searchRoots: [workingURL]
)
#expect(candidates.count == 1)
#expect(candidates.first?.sourceRootURL == workingURL.standardizedFileURL)
#expect(candidates.first?.displayName == workingURL.lastPathComponent)
#expect(candidates.first?.detectedKinds.contains(.mod) == true)
}
@Test func javaProviderDeduplicatesCaseVariantSourceRoots() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let upperRootURL = workingURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true)
let lowerRootURL = workingURL.appendingPathComponent("curseforge/minecraft", isDirectory: true)
let firstInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleOne", isDirectory: true)
let secondInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleTwo", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
for instanceURL in [firstInstanceURL, secondInstanceURL] {
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
}
guard fileManager.fileExists(atPath: lowerRootURL.path) else {
return
}
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
searchRoots: [upperRootURL, lowerRootURL]
)
#expect(candidates.count == 1)
#expect(sourceIdentityKey(for: candidates[0].sourceRootURL) == sourceIdentityKey(for: upperRootURL))
}
@Test func javaAggregateRootDiscoversNestedInstanceItems() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(
at: firstInstanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: firstInstanceURL.appendingPathComponent("mods/ExampleMod.jar"))
try fileManager.createDirectory(
at: secondInstanceURL.appendingPathComponent("resourcepacks", isDirectory: true),
withIntermediateDirectories: true
)
try Data("zip".utf8).write(to: secondInstanceURL.appendingPathComponent("resourcepacks/ExamplePack.zip"))
let items = try JavaContentScanner.discoverItems(in: workingURL)
let snapshots = JavaContentScanner.collectionSnapshots(in: workingURL)
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
#expect(items.contains { $0.contentKind == .resourcePack && $0.folderName == "ExamplePack.zip" })
#expect(snapshots.map(\.folderName).contains("a/b/c/mods"))
#expect(snapshots.map(\.folderName).contains("a/e/f/resourcepacks"))
}
@Test func sourceRestorationComparesDuplicateCollectionNamesWithoutCrashing() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
let collectionSnapshots = [
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 100),
childDirectoryCount: 1,
fingerprint: "a/b/c/mods::1::100"
),
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 200),
childDirectoryCount: 2,
fingerprint: "x/y/z/mods::2::200"
)
]
var source = MinecraftSource(
folderURL: workingURL,
origin: .javaLocalFolder(bookmarkData: nil),
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
),
availability: .available
)
source.edition = .java
source.snapshot = SourceSnapshot(
sourceID: workingURL,
rootModifiedDate: nil,
collectionSnapshots: collectionSnapshots,
itemSnapshots: []
)
#expect(SourceRestoration.needsReconcile(source) { _, _ in collectionSnapshots } == false)
#expect(
SourceRestoration.needsReconcile(source) { _, _ in
[
collectionSnapshots[0],
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 300),
childDirectoryCount: 3,
fingerprint: "x/y/z/mods::3::300"
)
]
} == true
)
}
@Test func sourceLibraryAddSourceCandidatePreservesJavaAggregateProvider() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let instanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("jar".utf8).write(to: modURL)
let candidate = SourceCandidate(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
edition: .java,
sourceRootURL: workingURL,
displayName: workingURL.lastPathComponent,
confidence: .strong,
reason: "Found multiple Java sources",
detectedKinds: [.mod]
)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
let library = SourceLibrary(sourceAccessMethod: access)
let sourceID = await library.addSource(candidate: candidate)
guard let source = library.source(withID: sourceID) else {
Issue.record("Expected added source")
return
}
#expect(source.folderURL == workingURL.standardizedFileURL)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
let items = try JavaContentScanner.discoverItems(in: source.folderURL)
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
}
@Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws {
let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let instanceURL = rootURL.appendingPathComponent("Better MC [NEOFORGE] BMC5", isDirectory: true)
let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar")
defer { try? fileManager.removeItem(at: rootURL) }
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("jar".utf8).write(to: modURL)
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("resourcepacks", isDirectory: true),
withIntermediateDirectories: true
)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
let library = SourceLibrary(sourceAccessMethod: access)
let sourceID = await library.addSource(at: rootURL)
guard let source = library.source(withID: sourceID) else {
Issue.record("Expected added source")
return
}
#expect(source.folderURL == instanceURL.standardizedFileURL)
#expect(source.origin.kind == .localFolder)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
}
@Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws {
@ -771,119 +1324,6 @@ struct World_Manager_for_MinecraftTests {
}
}
@Test func packageInstallerPreparesCompleteWorldContents() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let worldURL = workingURL.appendingPathComponent("World", isDirectory: true)
let archiveURL = workingURL.appendingPathComponent("World.mcworld", isDirectory: false)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(
at: worldURL.appendingPathComponent("db", isDirectory: true),
withIntermediateDirectories: true
)
try Data([1, 2, 3]).write(to: worldURL.appendingPathComponent("level.dat"))
try Data([4, 5, 6]).write(to: worldURL.appendingPathComponent("db/chunk.bin"))
try makeArchive(from: worldURL, to: archiveURL)
let plan = try MinecraftPackageInstaller.preparePackage(at: archiveURL)
defer { plan.cleanup() }
#expect(plan.payloads.count == 1)
#expect(plan.payloads[0].contentType == .world)
#expect(
fileManager.fileExists(
atPath: plan.payloads[0].preparedDirectoryURL.appendingPathComponent("db/chunk.bin").path
)
)
}
@Test func packageInstallerExpandsNestedAddonPackages() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let behaviorURL = workingURL.appendingPathComponent("Behavior", isDirectory: true)
let resourceURL = workingURL.appendingPathComponent("Resource", isDirectory: true)
let addonRootURL = workingURL.appendingPathComponent("Addon", isDirectory: true)
let behaviorArchiveURL = addonRootURL.appendingPathComponent("Behavior.mcpack")
let resourceArchiveURL = addonRootURL.appendingPathComponent("Resource.mcpack")
let addonURL = workingURL.appendingPathComponent("Combined.mcaddon")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: behaviorURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: resourceURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: addonRootURL, withIntermediateDirectories: true)
try testPackManifest(name: "Behavior", uuid: "11111111-1111-1111-1111-111111111111", moduleType: "data")
.write(to: behaviorURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
try testPackManifest(name: "Resource", uuid: "22222222-2222-2222-2222-222222222222", moduleType: "resources")
.write(to: resourceURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
try makeArchive(from: behaviorURL, to: behaviorArchiveURL)
try makeArchive(from: resourceURL, to: resourceArchiveURL)
try makeArchive(from: addonRootURL, to: addonURL)
let plan = try MinecraftPackageInstaller.preparePackage(at: addonURL)
defer { plan.cleanup() }
#expect(Set(plan.payloads.map(\.contentType)) == [.behaviorPack, .resourcePack])
}
@Test func localFolderInstallerUsesUniqueAdditiveDestinations() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let sourceWorldURL = workingURL.appendingPathComponent("PreparedWorld", isDirectory: true)
let destinationRootURL = workingURL.appendingPathComponent("Destination", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: sourceWorldURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
try Data([1]).write(to: sourceWorldURL.appendingPathComponent("level.dat"))
let plan = try MinecraftPackageInstaller.prepareDirectory(at: sourceWorldURL, contentType: .world)
defer { plan.cleanup() }
let source = MinecraftSource(folderURL: destinationRootURL, availability: .available)
let access = LocalFolderSourceAccess()
let first = try await access.install(plan.payloads[0], in: source)
let second = try await access.install(plan.payloads[0], in: source)
#expect(first.destinationName != second.destinationName)
#expect(
fileManager.fileExists(
atPath: destinationRootURL
.appendingPathComponent("minecraftWorlds/\(first.destinationName)/level.dat")
.path
)
)
#expect(
fileManager.fileExists(
atPath: destinationRootURL
.appendingPathComponent("minecraftWorlds/\(second.destinationName)/level.dat")
.path
)
)
}
@Test func zipReaderRejectsParentTraversalEntry() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let archiveURL = workingURL.appendingPathComponent("Unsafe.mcworld")
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
try makeStoredArchive(entries: [("../escape.txt", Data("escape".utf8))], to: archiveURL)
do {
_ = try ZipArchiveReader(url: archiveURL)
Issue.record("Expected the unsafe archive path to be rejected.")
} catch let error as ZipArchiveReaderError {
switch error {
case .unsafeEntryPath(let path):
#expect(path == "../escape.txt")
default:
Issue.record("Expected unsafeEntryPath but received \(error).")
}
}
}
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
@ -1064,6 +1504,106 @@ struct World_Manager_for_MinecraftTests {
#expect(restored[0].lastScanDate == legacyRecord.lastScanDate)
}
@Test func sourceRestorationPreservesJavaProviderResolvedLocalFolder() async throws {
let sourceURL = URL(fileURLWithPath: "/tmp/JavaInstance", isDirectory: true)
let accessDescriptor = SourceAccessDescriptor(
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
let record = PersistedSourceRecord(
sourceID: sourceURL,
folderURL: sourceURL,
origin: .localFolder(bookmarkData: nil),
accessDescriptor: accessDescriptor,
availability: .available,
bookmarkData: nil,
displayName: "Java Instance",
rawItems: [],
snapshot: nil,
lastScanDate: nil,
needsRepair: false
)
let source = SourceRestoration.restoredSource(from: record) { _, _ in "" }
#expect(source.origin.kind == .localFolder)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor == accessDescriptor)
}
@Test func javaRestoredSnapshotDoesNotRequestRefreshWhenUnchanged() async throws {
let fileManager = FileManager.default
let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let modURL = sourceURL.appendingPathComponent("mods/ExampleMod.jar")
defer { try? fileManager.removeItem(at: sourceURL) }
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("jar".utf8).write(to: modURL)
let item = MinecraftContentItem(
folderURL: modURL,
folderName: modURL.lastPathComponent,
contentType: .resourcePack,
sourceEdition: .java,
contentKind: .mod,
platformType: .java(.mod),
collectionRootURL: modURL.deletingLastPathComponent(),
displayName: "ExampleMod",
capabilities: .java(contentType: .mod),
platformMetadata: .java(JavaContentMetadata())
)
var source = MinecraftSource(
folderURL: sourceURL,
origin: .localFolder(bookmarkData: nil),
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
),
availability: .available
)
source.providerID = JavaLocalFolderSourceAccess().accessorIdentifier
source.edition = .java
SourceRestoration.applyRestoredItemState(
[item],
lastScanDate: Date(timeIntervalSince1970: 1_000),
snapshot: nil,
to: &source
)
source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: sourceURL)
let record = PersistedSourceRecord(
sourceID: source.id,
folderURL: source.folderURL,
origin: source.origin,
accessDescriptor: source.accessDescriptor,
availability: source.availability,
bookmarkData: nil,
displayName: source.displayName,
rawItems: source.rawItems,
snapshot: source.snapshot,
lastScanDate: source.lastScanDate,
needsRepair: false
)
let refreshReason = SourceRestoration.startupRefreshReason(
for: source,
persistedRecord: record
) { url, edition in
switch edition {
case .bedrock:
return WorldScanner.collectionSnapshots(in: url)
case .java:
return JavaContentScanner.collectionSnapshots(in: url)
}
}
#expect(refreshReason == nil)
#expect(source.snapshot?.collectionSnapshots.first?.childDirectoryCount == 1)
}
@Test func connectedDeviceSourceFactoryCreatesStableSyntheticIdentifier() async throws {
let device = ConnectedDevice(
udid: "00008110-001234560E90001E",
@ -1235,24 +1775,6 @@ 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()
@ -1382,82 +1904,6 @@ private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throw
}
}
private func testPackManifest(name: String, uuid: String, moduleType: String) -> String {
"""
{
"format_version": 2,
"header": {
"name": "\(name)",
"description": "\(name)",
"uuid": "\(uuid)",
"version": [1, 0, 0]
},
"modules": [
{
"type": "\(moduleType)",
"uuid": "\(UUID().uuidString)",
"version": [1, 0, 0]
}
]
}
"""
}
private func makeStoredArchive(entries: [(String, Data)], to archiveURL: URL) throws {
var archive = Data()
var centralDirectory = Data()
for (path, contents) in entries {
let pathData = Data(path.utf8)
let localHeaderOffset = UInt32(archive.count)
appendLE(UInt32(0x04034b50), to: &archive)
appendLE(UInt16(20), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt32(0), to: &archive)
appendLE(UInt32(contents.count), to: &archive)
appendLE(UInt32(contents.count), to: &archive)
appendLE(UInt16(pathData.count), to: &archive)
appendLE(UInt16(0), to: &archive)
archive.append(pathData)
archive.append(contents)
appendLE(UInt32(0x02014b50), to: &centralDirectory)
appendLE(UInt16(20), to: &centralDirectory)
appendLE(UInt16(20), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt32(0), to: &centralDirectory)
appendLE(UInt32(contents.count), to: &centralDirectory)
appendLE(UInt32(contents.count), to: &centralDirectory)
appendLE(UInt16(pathData.count), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt16(0), to: &centralDirectory)
appendLE(UInt32(0), to: &centralDirectory)
appendLE(localHeaderOffset, to: &centralDirectory)
centralDirectory.append(pathData)
}
let centralDirectoryOffset = UInt32(archive.count)
archive.append(centralDirectory)
appendLE(UInt32(0x06054b50), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(0), to: &archive)
appendLE(UInt16(entries.count), to: &archive)
appendLE(UInt16(entries.count), to: &archive)
appendLE(UInt32(centralDirectory.count), to: &archive)
appendLE(centralDirectoryOffset, to: &archive)
appendLE(UInt16(0), to: &archive)
try archive.write(to: archiveURL, options: .atomic)
}
private enum ArchiveTestError: LocalizedError {
case failedToCreateArchive(String)

View File

@ -2,7 +2,7 @@
## Summary
World Manager can browse and install Minecraft Bedrock content on a trusted iPhone or iPad from macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
World Manager can browse Minecraft Bedrock content from a trusted iPhone or iPad on macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
@ -87,18 +87,6 @@ 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:
@ -144,6 +132,3 @@ These commands require a trusted connected device and generally need to run outs
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
- 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.

View File

@ -0,0 +1,248 @@
# Provider Architecture Design
World Manager should treat Minecraft libraries as sources of content units that
can be discovered, inspected, cached, displayed, materialized, exported, and
eventually copied between sources. Bedrock local folders, Bedrock iOS devices,
Java local folders, and possible future platforms should be cohesive provider
modules behind one engine contract.
## Goals
- Keep provider-specific knowledge inside provider modules.
- Give the UI a uniform model for equivalent concepts: name, edition, kind,
source, icon, dates, size, availability, progress, and actions.
- Preserve variable metadata for platform details that do not translate across
editions.
- Allow scan workflows to stream events instead of forcing every provider into
the same fixed stages.
- Keep connected-device and remote-like sources free to throttle work more
aggressively than local filesystem sources.
## Shape
```text
Platforms/Bedrock
BedrockLocalFolderProvider
BedrockIOSDeviceProvider
BedrockContentScanner
BedrockMetadataReader
BedrockExporter
BedrockMaterializer
Platforms/Java
JavaLocalFolderProvider
JavaContentScanner
JavaMetadataReader
JavaExporter
JavaMaterializer
Engine
ProviderRegistry
SourceEngine
Scan orchestration
Cache/snapshot persistence
Generic indexing/search/projection
Generic action dispatch
UI
Generic source and item surfaces
Provider/edition-specific detail sections
```
## Core Concepts
### Local Folder Intake
The folder picker should not decide the platform. A picked folder is a local
access root; providers decide whether it contains Bedrock, Java, or another
platform.
```text
User picks folder
-> provider registry asks local providers to probe it
-> strongest probe chooses provider, edition, and source root
-> source is stored as a local folder with providerID/accessDescriptor
-> scans route through the selected provider
```
This keeps filesystem access separate from Minecraft format knowledge. For
example, selecting a wrapper folder that contains one Java modpack instance can
resolve to the nested instance folder while still using local folder access.
```swift
struct SourceProbeResult {
let providerID: PlatformProviderID
let edition: MinecraftEdition
let confidence: SourceProbeConfidence
let sourceRootURL: URL
let displayName: String
let detectedKinds: Set<MinecraftContentKind>
let warnings: [String]
}
```
### Provider
A provider is the unit that knows a platform and access method. A provider can
represent an edition/access pairing such as Bedrock local folder, Bedrock iOS
device, or Java local folder.
```swift
protocol MinecraftPlatformProvider: Sendable {
var id: PlatformProviderID { get }
var displayName: String { get }
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
func capabilities(for source: MinecraftSource) async -> SourceCapabilities
func scan(_ request: ProviderScanRequest) -> AsyncThrowingStream<ProviderEvent, Error>
func materialize(_ unit: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
func export(_ unit: MinecraftContentItem, in source: MinecraftSource, request: ExportRequest) async throws -> URL
}
```
The current `SourceAccessMethod` is already close to this role. The refactor
should evolve it rather than replace the whole source system at once.
### Content Unit
The engine should pass around an edition-aware content unit with a strong common
surface and boxed platform metadata.
```swift
struct MinecraftContentItem {
let id: URL
let folderURL: URL
let folderName: String
let sourceEdition: MinecraftEdition
let contentKind: MinecraftContentKind
let platformType: MinecraftPlatformContentType
let collectionRootURL: URL
var displayName: String
var iconURL: URL?
var modifiedDate: Date?
var sizeBytes: Int64?
var capabilities: ContentItemCapabilities
var platformMetadata: PlatformContentMetadata
}
```
The model can retain compatibility shims during migration, but new code should
prefer edition, kind, capabilities, and boxed metadata over Bedrock-only fields.
### Metadata
Generic UI should avoid flattening provider metadata. Platform details should be
boxed and rendered by edition/provider-aware detail sections.
```swift
enum PlatformContentMetadata: Hashable, Sendable, Codable {
case bedrock(BedrockContentMetadata)
case java(JavaContentMetadata)
case none
}
```
### Access Status
Availability should be richer than local-vs-device.
```swift
enum SourceAccessMode: String, Hashable, Sendable, Codable {
case localFileSystem
case securityScopedLocalFolder
case usbDevice
case networkDevice
case archive
case unknown
}
struct SourceAccessStatus: Hashable, Sendable, Codable {
var availability: SourceAvailability
var mode: SourceAccessMode
var displayName: String
var iconSystemName: String
var statusText: String?
var warningText: String?
}
```
### Event-Driven Scanning
Providers should be able to stream discoveries, metadata, progress, and warnings.
```swift
enum ProviderEvent: Sendable {
case accessStatusChanged(SourceAccessStatus)
case stageUpdated(WorkStage)
case discovered(MinecraftContentItem)
case inspected(MinecraftContentItem)
case warning(ProviderWarning)
}
```
The engine consumes events, updates source state, updates indexes, persists
snapshots, and exposes UI-ready projections.
### Source Candidate Discovery
Source candidate discovery is separate from source content discovery. Candidate
discovery answers whether a potential source exists in the current environment;
content discovery scans an accepted source for worlds, packs, mods, and other
items.
```text
Engine asks providers for source candidates
-> each provider uses its own bounded discovery process
-> providers stream candidate events
-> engine deduplicates and filters already-added sources
-> UI shows suggestions that the user can accept
```
For example, the Java local provider can check known macOS launcher roots and
shallow-search likely instance folders, while Bedrock local folders can remain a
no-op and rely on folder picking. Connected-device providers can later emit
device-backed candidates from USB or network discovery.
## Responsibilities
### Engine Owns
- Provider registration/routing.
- Source candidate discovery orchestration and deduplication.
- Source lifecycle and persistence.
- Scan task ownership, cancellation, and worker limits.
- Cache and snapshot persistence hooks.
- Generic item search, sorting, counts, and projections.
- Generic action dispatch and user-facing error normalization.
### Provider Owns
- Access method details.
- Source candidate discovery strategy.
- Discovery layout and content markers.
- Metadata parsing.
- Platform relationships.
- Materialization.
- Export formats.
- Provider-specific progress stages and warnings.
- Provider-specific detail metadata.
## Current Fit
Existing app pieces already map to this design:
- `SourceAccessMethod`: provider-like boundary.
- `SourceAccessCoordinator`: provider registry/router.
- `SourceLibrary`: engine/orchestrator.
- `MinecraftSource`: source session and persisted model.
- `SourcePersistenceStore`: persistence.
- `SourceContentIndexer`: generic indexing plus Bedrock-specific relationship
logic that should be split.
- `ContentItemActionService` and `ContentPackageExporter`: action/export layer
that should become capability/provider aware.
The main mismatch is that shared models and UI currently carry Bedrock-specific
types and fields directly.

View File

@ -0,0 +1,92 @@
# Provider Refactor Migration Plan
This plan moves the app from a Bedrock-oriented source-access architecture to an
engine/platform-provider architecture while keeping behavior working at each
step.
## Phase 1: Document and Name the Boundary
- Add provider architecture documentation.
- Add neutral model names alongside existing names:
- `MinecraftEdition`
- `MinecraftContentKind`
- `MinecraftPlatformContentType`
- `PlatformContentMetadata`
- `ContentItemCapabilities`
- `SourceAccessStatus`
- `WorkStage`
- `ProviderEvent`
- Keep existing `MinecraftContentType` as a compatibility alias/wrapper until
call sites move.
## Phase 2: Neutralize Content Items
- Add edition, kind, platform type, capabilities, and platform metadata to
`MinecraftContentItem`.
- Preserve existing Bedrock fields as computed compatibility accessors where
practical.
- Move Bedrock world and pack metadata into `BedrockContentMetadata`.
- Update search text to pull from generic fields and provider metadata.
- Update tests/fixtures to construct items through the new neutral initializer.
## Phase 3: Provider-Shaped Access
- Introduce provider protocol types as a superset of current source access.
- Adapt `SourceAccessMethod` to emit provider IDs and access status.
- Register providers through a provider registry/coordinator.
- Rename current generic local folder access to Bedrock local folder access.
- Keep a compatibility typealias or wrapper named `LocalFolderSourceAccess` until
all call sites are migrated.
## Phase 4: Split Bedrock Platform Module
- Move Bedrock-specific scanner/metadata/export code under a Bedrock platform
namespace/folder.
- Rename `WorldScanner` to `BedrockContentScanner`.
- Rename `MinecraftContentMetadataReader` to `BedrockContentMetadataReader`.
- Keep temporary wrappers for Quick Look and legacy call sites.
- Move Bedrock relationship building out of generic indexing.
## Phase 5: Event-Driven Scan Pipeline
- Add provider events and work stages.
- Let providers report progress stages.
- Let the engine consume discovered/inspected events.
- Preserve existing scan-stage behavior until provider events fully replace it.
- Keep worker limits in the engine, with provider-declared concurrency policy as
a later extension.
## Phase 6: Capability-Aware Actions
- Move archive extension and portable export format selection off
`MinecraftContentType`.
- Add item/provider capabilities for reveal/export/share/copy.
- Adapt `ContentItemActionService` and exporters to route by provider/platform
type.
- Keep Bedrock package output unchanged.
## Phase 7: Java Local Provider
- Add Java local provider as read-only initially.
- Discover Java saves, resource packs, and datapacks.
- Add Java metadata reader for `level.dat` and `pack.mcmeta` incrementally.
- Export Java folder-backed content as `.zip` where supported.
- Do not add Java-Bedrock conversion in this phase.
## Phase 8: Cleanup
- Remove compatibility aliases after UI, tests, Quick Look, and exporters use
provider-aware models.
- Rename UI labels from Bedrock-specific categories where appropriate.
- Add provider fixtures and contract tests.
- Keep build/test verification passing after each phase.
## Verification
At each milestone:
- Run Swift tests.
- Run the app target build.
- Check existing Bedrock local and connected-device behavior remains intact.
- Add focused tests for model migration, indexing, export format selection, and
provider event consumption.