Compare commits

..

12 Commits

33 changed files with 3823 additions and 205 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@ -3,6 +3,47 @@
import Foundation 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 { nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable {
case world = "World" case world = "World"
case behaviorPack = "Behavior Pack" 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 { nonisolated var archiveExtension: String {
switch self { switch self {
case .world: 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 { nonisolated enum PackSource: String, Hashable, Sendable, Codable {
case referencedByWorld case referencedByWorld
case embeddedInWorld case embeddedInWorld
@ -113,11 +234,77 @@ nonisolated struct PackMetadataDetails: Hashable, Sendable, Codable {
var minimumEngineVersion: String? 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 dataPacks: [JavaPackReference]
nonisolated init(
world: JavaWorldMetadata? = nil,
pack: JavaPackMetadata? = nil,
dataPacks: [JavaPackReference] = []
) {
self.world = world
self.pack = pack
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 description: String?
}
nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable {
let id: String
var name: String
var pathHint: String?
}
nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
let id: URL let id: URL
let folderURL: URL let folderURL: URL
let folderName: String let folderName: String
let contentType: MinecraftContentType let contentType: MinecraftContentType
let sourceEdition: MinecraftEdition
let contentKind: MinecraftContentKind
let platformType: MinecraftPlatformContentType
let collectionRootURL: URL let collectionRootURL: URL
var displayName: String var displayName: String
var iconURL: URL? var iconURL: URL?
@ -125,11 +312,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
var lastPlayedDate: Date? var lastPlayedDate: Date?
var modifiedDate: Date? var modifiedDate: Date?
var sizeBytes: Int64? var sizeBytes: Int64?
var packUUID: String? var capabilities: ContentItemCapabilities
var packVersion: String? var platformMetadata: PlatformContentMetadata
var packMetadataDetails: PackMetadataDetails? var packUUID: String? {
var packReferences: [ContentPackReference] didSet { syncBedrockMetadataFromCompatibilityFields() }
var worldMetadata: WorldMetadata? }
var packVersion: String? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var packMetadataDetails: PackMetadataDetails? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var packReferences: [ContentPackReference] {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var worldMetadata: WorldMetadata? {
didSet { syncBedrockMetadataFromCompatibilityFields() }
}
var metadataLoaded: Bool var metadataLoaded: Bool
var previewLoaded: Bool var previewLoaded: Bool
var sizeLoaded: Bool var sizeLoaded: Bool
@ -138,6 +337,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
folderURL: URL, folderURL: URL,
folderName: String, folderName: String,
contentType: MinecraftContentType, contentType: MinecraftContentType,
sourceEdition: MinecraftEdition? = nil,
contentKind: MinecraftContentKind? = nil,
platformType: MinecraftPlatformContentType? = nil,
collectionRootURL: URL, collectionRootURL: URL,
displayName: String? = nil, displayName: String? = nil,
iconURL: URL? = nil, iconURL: URL? = nil,
@ -145,6 +347,8 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
lastPlayedDate: Date? = nil, lastPlayedDate: Date? = nil,
modifiedDate: Date? = nil, modifiedDate: Date? = nil,
sizeBytes: Int64? = nil, sizeBytes: Int64? = nil,
capabilities: ContentItemCapabilities? = nil,
platformMetadata: PlatformContentMetadata? = nil,
packUUID: String? = nil, packUUID: String? = nil,
packVersion: String? = nil, packVersion: String? = nil,
packMetadataDetails: PackMetadataDetails? = nil, packMetadataDetails: PackMetadataDetails? = nil,
@ -158,6 +362,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.folderURL = folderURL self.folderURL = folderURL
self.folderName = folderName self.folderName = folderName
self.contentType = contentType self.contentType = contentType
self.sourceEdition = sourceEdition ?? .bedrock
self.contentKind = contentKind ?? contentType.kind
self.platformType = platformType ?? .bedrock(contentType)
self.collectionRootURL = collectionRootURL self.collectionRootURL = collectionRootURL
self.displayName = displayName ?? folderName self.displayName = displayName ?? folderName
self.iconURL = iconURL self.iconURL = iconURL
@ -165,6 +372,16 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.lastPlayedDate = lastPlayedDate self.lastPlayedDate = lastPlayedDate
self.modifiedDate = modifiedDate self.modifiedDate = modifiedDate
self.sizeBytes = sizeBytes 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.packUUID = packUUID?.lowercased()
self.packVersion = packVersion self.packVersion = packVersion
self.packMetadataDetails = packMetadataDetails self.packMetadataDetails = packMetadataDetails
@ -175,6 +392,22 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
self.sizeLoaded = sizeLoaded 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 { nonisolated var folderID: String {
folderName folderName
} }

View File

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

View File

@ -45,20 +45,32 @@ nonisolated enum DeviceContainerAccessMode: String, Hashable, Sendable, Codable
nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable { nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
case localFolder(bookmarkData: Data?) case localFolder(bookmarkData: Data?)
case javaLocalFolder(bookmarkData: Data?)
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer) case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier { nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
switch self { switch self {
case .localFolder: case .localFolder:
return LocalFolderSourceAccess().accessorIdentifier return LocalFolderSourceAccess().accessorIdentifier
case .javaLocalFolder:
return JavaLocalFolderSourceAccess().accessorIdentifier
case .connectedDevice: case .connectedDevice:
return AppleMobileDeviceSourceAccess().accessorIdentifier return AppleMobileDeviceSourceAccess().accessorIdentifier
} }
} }
nonisolated var defaultEdition: MinecraftEdition {
switch self {
case .localFolder, .connectedDevice:
return .bedrock
case .javaLocalFolder:
return .java
}
}
nonisolated var kind: MinecraftSourceKind { nonisolated var kind: MinecraftSourceKind {
switch self { switch self {
case .localFolder: case .localFolder, .javaLocalFolder:
return .localFolder return .localFolder
case .connectedDevice: case .connectedDevice:
return .connectedDevice return .connectedDevice
@ -67,7 +79,7 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy { nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
switch self { switch self {
case .localFolder: case .localFolder, .javaLocalFolder:
return .eagerFullScan return .eagerFullScan
case .connectedDevice: case .connectedDevice:
return .staged return .staged
@ -76,12 +88,35 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
nonisolated var defaultCapabilities: SourceCapabilities { nonisolated var defaultCapabilities: SourceCapabilities {
switch self { switch self {
case .localFolder: case .localFolder, .javaLocalFolder:
return .localFolder return .localFolder
case .connectedDevice: case .connectedDevice:
return .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 { nonisolated enum MinecraftSourceKind: String, Hashable, Sendable, Codable {

View File

@ -24,6 +24,114 @@ nonisolated struct SourceAccessDescriptor: Hashable, Sendable, Codable {
var refreshStrategy: SourceRefreshStrategy 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 { nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
let id: URL let id: URL
var displayName: String var displayName: String

View File

@ -16,7 +16,7 @@ struct ContentItemActionService: Sendable {
} }
nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType { 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( nonisolated func persistExternalRepresentation(

View File

@ -37,7 +37,11 @@ enum ContentPackageExporter {
try fileManager.removeItem(at: archiveURL) 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 return archiveURL
} }
@ -46,7 +50,7 @@ enum ContentPackageExporter {
} }
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String { 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 { nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
@ -209,7 +213,7 @@ enum ContentPackageExporter {
return requestDirectoryURL return requestDirectoryURL
.appendingPathComponent(suggestedBaseFilename(for: item)) .appendingPathComponent(suggestedBaseFilename(for: item))
.appendingPathExtension(item.contentType.archiveExtension) .appendingPathExtension(archiveExtension(for: item))
} }
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String { 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 { nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension let requiredExtension = archiveExtension(for: item)
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension { if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL return normalizedDestinationURL
@ -280,6 +284,32 @@ enum ContentPackageExporter {
return normalizedDestinationURL.appendingPathExtension(requiredExtension) 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( nonisolated private static func uniqueArchiveURL(
in directoryURL: URL, in directoryURL: URL,
baseName: String, baseName: String,

View File

@ -25,14 +25,32 @@ struct ContentItemFileFacts: Sendable {
self.approximateAgeText = nil self.approximateAgeText = nil
} }
switch item.contentType { switch item.sourceEdition {
case .world: case .bedrock:
let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true) switch item.contentType {
self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path) case .world:
? "LevelDB world storage" let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
: "Flat-file world storage" self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path)
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: ? "LevelDB world storage"
self.storageFormatLabel = "Manifest-based package" : "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 import Foundation
enum WorldScanner { typealias WorldScanner = BedrockContentScanner
enum BedrockContentScanner {
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem { nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
let fileManager = FileManager.default let fileManager = FileManager.default
var sizedItem = item var sizedItem = item
@ -20,6 +22,49 @@ enum WorldScanner {
await packReferenceIndexStore.reset(for: sourceRootURL) 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( nonisolated static func discoverItems(
in searchRootURL: URL, in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
@ -162,7 +207,7 @@ enum WorldScanner {
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager) ? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
: nil : nil
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata) 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) { if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
enrichedItem.packUUID = manifestMetadata.uuid enrichedItem.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version enrichedItem.packVersion = manifestMetadata.version
@ -322,11 +367,11 @@ enum WorldScanner {
return worldMetadata?.lastPlayedDate 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 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( guard let enumerator = fileManager.enumerator(
at: folderURL, at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
@ -580,4 +625,593 @@ 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 let packMetadata = metadata?.pack {
enrichedItem.platformMetadata = .java(JavaContentMetadata(pack: packMetadata))
}
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() private let packReferenceIndexStore = PackReferenceIndexStore()

View File

@ -0,0 +1,285 @@
// 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 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,
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,
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,
description: textValue(from: packObject["description"])
)
}
nonisolated private static func modMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? {
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?)? {
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 displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
if displayName != nil || logoFile != nil {
return (displayName, logoFile)
}
}
return nil
}
nonisolated private static func modJSONMetadata(
from archive: ZipArchiveReader,
entryName: String
) -> (displayName: String?, iconPath: String?)? {
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
}
return (
(jsonObject["name"] as? String)?.nilIfBlank,
iconPath?.nilIfBlank
)
}
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 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
}
}
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? let minimumEngineVersion: String?
} }
enum MinecraftContentMetadataReader { typealias MinecraftContentMetadataReader = BedrockContentMetadataReader
enum BedrockContentMetadataReader {
nonisolated static func displayName( nonisolated static func displayName(
for directoryURL: URL, for directoryURL: URL,
contentType: MinecraftContentType, contentType: MinecraftContentType,

View File

@ -6,6 +6,11 @@ import Foundation
import OSLog import OSLog
import UniformTypeIdentifiers import UniformTypeIdentifiers
enum SourceLibraryCommand: Sendable {
case discoverSourceCandidates
case refreshAllSources
}
@MainActor @MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting {
private static let enrichmentWorkerCount = 4 private static let enrichmentWorkerCount = 4
@ -28,10 +33,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var sourceCandidates: [SourceCandidate] = []
@Published var isDiscoveringSourceCandidates = false
@Published var isRestoringPersistedSources = true @Published var isRestoringPersistedSources = true
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:] private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
private var candidateDiscoveryTask: Task<Void, Never>?
private var connectedDeviceRefreshTask: Task<Void, Never>? private var connectedDeviceRefreshTask: Task<Void, Never>?
private var localSourceRefreshTask: Task<Void, Never>? private var localSourceRefreshTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore private let persistenceStore: SourcePersistenceStore
@ -80,6 +88,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
deinit { deinit {
connectedDeviceRefreshTask?.cancel() connectedDeviceRefreshTask?.cancel()
localSourceRefreshTask?.cancel() localSourceRefreshTask?.cancel()
candidateDiscoveryTask?.cancel()
automaticSyncTasks.values.forEach { $0.cancel() } automaticSyncTasks.values.forEach { $0.cancel() }
scanTasks.values.forEach { $0.cancel() } scanTasks.values.forEach { $0.cancel() }
} }
@ -92,6 +101,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
visibleSources 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? { func sourceID(forItemID itemID: URL) -> URL? {
sourceIDByItemID[itemID] sourceIDByItemID[itemID]
} }
@ -110,6 +135,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
connectedDeviceRefreshTask = nil connectedDeviceRefreshTask = nil
localSourceRefreshTask?.cancel() localSourceRefreshTask?.cancel()
localSourceRefreshTask = nil localSourceRefreshTask = nil
candidateDiscoveryTask?.cancel()
candidateDiscoveryTask = nil
for task in automaticSyncTasks.values { for task in automaticSyncTasks.values {
task.cancel() task.cancel()
@ -137,40 +164,121 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
) )
} }
func addSource(at url: URL) -> URL { func perform(_ command: SourceLibraryCommand) {
let normalizedURL = url.standardizedFileURL switch command {
let bookmarkData = securityScopedBookmarkData(for: normalizedURL) 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 }) { func addSource(at url: URL) async -> URL {
updateSource(normalizedURL) { source in 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 { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData 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 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) startScan(for: existingSourceID, mode: .fullScan)
return normalizedURL return existingSourceID
} }
let source = MinecraftSource( var source = MinecraftSource(
folderURL: normalizedURL, folderURL: normalizedURL,
bookmarkData: bookmarkData, bookmarkData: bookmarkData,
accessDescriptor: SourceAccessDescriptor( accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier, accessorIdentifier: providerID,
kind: .localFolder, kind: .localFolder,
refreshStrategy: .eagerFullScan 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 @discardableResult
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL { func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
if sources.contains(where: { $0.id == source.id }) { if let existingSourceID = existingSourceID(matching: source.id) {
updateSource(source.id) { existingSource in updateSource(existingSourceID) { existingSource in
existingSource.origin = source.origin existingSource.origin = source.origin
existingSource.accessDescriptor = source.accessDescriptor 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.availability = source.availability
existingSource.capabilities = source.capabilities existingSource.capabilities = source.capabilities
if existingSource.bookmarkData == nil { if existingSource.bookmarkData == nil {
@ -183,19 +291,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} else { } else {
var resolvedSource = source var resolvedSource = source
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource) 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 resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
sources.append(resolvedSource) sources.append(resolvedSource)
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
} }
if shouldPersist { if shouldPersist {
persistSourceIfAvailable(withID: source.id) persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id)
} }
if shouldScan { 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? { func source(withID sourceID: URL) -> MinecraftSource? {
@ -210,6 +321,42 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
startScan(for: sourceID, mode: .fullScan) 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] { func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
try await sourceAccessMethod.listItemContents(for: item, in: source) try await sourceAccessMethod.listItemContents(for: item, in: source)
} }
@ -320,6 +467,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.worldPackRelationships = index.worldPackRelationships source.worldPackRelationships = index.worldPackRelationships
source.displayItems = index.displayItems source.displayItems = index.displayItems
source.displayItemCountsByType = index.displayItemCountsByType source.displayItemCountsByType = index.displayItemCountsByType
source.displayItemCountsByKind = index.displayItemCountsByKind
} }
} }
@ -362,6 +510,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.displayItems = snapshot.displayItems source.displayItems = snapshot.displayItems
source.displayItemCountsByType = snapshot.displayItemCountsByType source.displayItemCountsByType = snapshot.displayItemCountsByType
source.displayItemCountsByKind = snapshot.displayItemCountsByKind
source.rawItems = snapshot.rawItems source.rawItems = snapshot.rawItems
source.logicalPacks = snapshot.logicalPacks source.logicalPacks = snapshot.logicalPacks
source.logicalWorlds = snapshot.logicalWorlds source.logicalWorlds = snapshot.logicalWorlds
@ -432,8 +581,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod) await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
} }
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] {
WorldScanner.collectionSnapshots(in: sourceURL) switch edition {
case .bedrock:
return WorldScanner.collectionSnapshots(in: sourceURL)
case .java:
return JavaContentScanner.collectionSnapshots(in: sourceURL)
}
} }
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
@ -523,6 +677,54 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
sourceIDByItemID = itemIndex 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 @discardableResult

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ struct SourceContentIndex {
let worldPackRelationships: [WorldPackRelationship] let worldPackRelationships: [WorldPackRelationship]
let displayItems: [MinecraftContentItem] let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int] let displayItemCountsByType: [MinecraftContentType: Int]
let displayItemCountsByKind: [MinecraftContentKind: Int]
} }
enum SourceContentIndexer { enum SourceContentIndexer {
@ -171,7 +172,8 @@ enum SourceContentIndexer {
packInstances: sortedPackInstances, packInstances: sortedPackInstances,
worldPackRelationships: worldRelationships, worldPackRelationships: worldRelationships,
displayItems: displayItems, 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 { private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
let candidateEmbedded = isEmbeddedWorldPack(candidate) let candidateEmbedded = isEmbeddedWorldPack(candidate)
let existingEmbedded = isEmbeddedWorldPack(existing) let existingEmbedded = isEmbeddedWorldPack(existing)

View File

@ -50,9 +50,10 @@ enum SourceScanExecutor {
host.updateSource(sourceID) { source in host.updateSource(sourceID) { source in
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) 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 host.updateSource(sourceID) { source in
source.availability = currentAvailability source.accessStatus = currentAccessStatus
source.availability = currentAccessStatus.availability
} }
let scanContextURL = source.folderURL let scanContextURL = source.folderURL
@ -89,22 +90,7 @@ enum SourceScanExecutor {
} }
} }
} }
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in let providerEventStream = sourceAccessMethod.scanEvents(for: source, mode: mode)
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 previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) }) let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) })
let previousSnapshotByItemID = Dictionary( let previousSnapshotByItemID = Dictionary(
@ -116,35 +102,61 @@ enum SourceScanExecutor {
var discoveredCollectionNames = Set<String>() var discoveredCollectionNames = Set<String>()
let discoveryStartTime = Date() let discoveryStartTime = Date()
for try await item in discoveryStream { for try await event in providerEventStream {
guard !Task.isCancelled else { guard !Task.isCancelled else {
break break
} }
discoveredCount += 1 switch event {
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) case .accessStatusChanged(let accessStatus):
let itemForIndex: MinecraftContentItem host.updateSource(sourceID) { source in
if shouldReconcileFromCache, source.accessStatus = accessStatus
let cachedItem = previousItemsByID[item.id], source.availability = accessStatus.availability
SourceScanPolicy.shouldReuseCachedItem( }
cachedItem, continue
forDiscoveredItem: item, case .stageUpdated(let stage):
source: source, host.updateSource(sourceID) { source in
previousSnapshot: previousSnapshotByItemID[item.id] source.scanStatus = stage.detail ?? stage.title
) { }
itemForIndex = cachedItem continue
} else { case .warning(let warning):
itemForIndex = item 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( if let snapshot = await index.addDiscoveredItem(
itemForIndex, itemForIndex,
discoveredCount: discoveredCount discoveredCount: discoveredCount
) { ) {
host.applySnapshot(snapshot, to: sourceID) host.applySnapshot(snapshot, to: sourceID)
} }
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
await enrichmentQueue.enqueue(item) await enrichmentQueue.enqueue(item)
}
} }
} }
@ -464,6 +476,7 @@ private actor EnrichmentWorkQueue {
struct SourceIndexSnapshot { struct SourceIndexSnapshot {
let displayItems: [MinecraftContentItem] let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int] let displayItemCountsByType: [MinecraftContentType: Int]
let displayItemCountsByKind: [MinecraftContentKind: Int]
let rawItems: [MinecraftContentItem] let rawItems: [MinecraftContentItem]
let logicalPacks: [LogicalPack] let logicalPacks: [LogicalPack]
let logicalWorlds: [LogicalWorld] let logicalWorlds: [LogicalWorld]
@ -631,6 +644,9 @@ private actor SourceIndexActor {
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1 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 metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
@ -652,6 +668,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType, displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -680,6 +697,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType, displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -712,6 +730,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType, displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -824,6 +843,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType, displayItemCountsByType: displayItemCountsByType,
displayItemCountsByKind: displayItemCountsByKind,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: logicalWorlds, logicalWorlds: logicalWorlds,

View File

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

View File

@ -18,26 +18,68 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
} }
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { 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 { 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 { do {
let devices = try await listConnectedDevices() let devices = try await listConnectedDevices()
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else { 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 { switch device.trustState {
case .trusted: case .trusted:
return .available availability = .available
statusText = nil
case .locked, .untrusted: case .locked, .untrusted:
return .limited availability = .limited
statusText = "Unlock and trust the device"
case .unavailable: 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 { } catch {
return .disconnected return SourceAccessStatus(
availability: .disconnected,
mode: fallbackMode,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device status unavailable",
warningText: error.localizedDescription
)
} }
} }

View File

@ -10,7 +10,10 @@ enum SourceDiscoveryMode: Sendable {
protocol SourceAccessMethod: Sendable { protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } 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 accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities
nonisolated func discoverItems( nonisolated func discoverItems(
@ -18,6 +21,10 @@ protocol SourceAccessMethod: Sendable {
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws ) 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 enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in 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] nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
@ -33,6 +40,17 @@ extension SourceAccessMethod {
String(reflecting: Self.self) 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 { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
SourceAccessDescriptor( SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier, accessorIdentifier: accessorIdentifier,
@ -42,8 +60,13 @@ extension SourceAccessMethod {
} }
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
_ = source await accessStatus(for: source).availability
return .unknown }
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 { nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -60,6 +83,64 @@ extension SourceAccessMethod {
_ = onDiscovered _ = 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 { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source _ = source
return item return item
@ -123,9 +204,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
nonisolated init( nonisolated init(
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(), localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
javaLocalFolderAccess: SourceAccessMethod = JavaLocalFolderSourceAccess(),
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
) { ) {
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess]) self.init(accessMethods: [localFolderAccess, javaLocalFolderAccess, connectedDeviceAccess])
} }
nonisolated init(accessMethods: [any SourceAccessMethod]) { nonisolated init(accessMethods: [any SourceAccessMethod]) {
@ -152,6 +234,64 @@ struct SourceAccessCoordinator: SourceAccessMethod {
fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).") 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( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,
@ -164,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 { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
accessMethod(for: source).accessDescriptor(for: source) accessMethod(for: source).accessDescriptor(for: source)
} }
@ -172,6 +319,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).availability(for: source) 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 { nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
return await accessMethod(for: source).capabilities(for: source) return await accessMethod(for: source).capabilities(for: source)
} }

View File

@ -3,11 +3,17 @@
import Foundation import Foundation
struct LocalFolderSourceAccess: SourceAccessMethod { typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess
struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder" nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
nonisolated init() {} nonisolated init() {}
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
BedrockContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source _ = source
return SourceAccessDescriptor( return SourceAccessDescriptor(
@ -18,9 +24,15 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
} }
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { 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 candidateURL: URL
let mode: SourceAccessMode
if case .localFolder(let bookmarkData) = source.origin, if case .localFolder(let bookmarkData) = source.origin,
let bookmarkData { let bookmarkData {
mode = .securityScopedLocalFolder
var isStale = false var isStale = false
if let resolvedURL = try? URL( if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData, resolvingBookmarkData: bookmarkData,
@ -33,10 +45,19 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
candidateURL = source.folderURL candidateURL = source.folderURL
} }
} else { } else {
mode = .localFileSystem
candidateURL = source.folderURL 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 { nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -191,3 +212,185 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
return components.first.map(String.init) 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,6 +7,8 @@ import SwiftUI
struct ItemDetailColumnView: View { struct ItemDetailColumnView: View {
let item: MinecraftContentItem? let item: MinecraftContentItem?
let source: MinecraftSource? let source: MinecraftSource?
let sourceCandidate: SourceCandidate?
let connectedDevice: ConnectedDeviceSidebarEntry?
let showsSourceDetails: Bool let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference] let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference] let resourcePacks: [ContentPackReference]
@ -22,11 +24,13 @@ struct ItemDetailColumnView: View {
let exportAction: () -> Void let exportAction: () -> Void
let revealAction: () -> Void let revealAction: () -> Void
let shareAction: (NSView?) -> Void let shareAction: (NSView?) -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let revealCandidateAction: (SourceCandidate) -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
var body: some View { var body: some View {
Group { Group {
if isEmpty { if let item {
} else if let item {
ItemDetailView( ItemDetailView(
item: item, item: item,
source: source, source: source,
@ -46,6 +50,24 @@ struct ItemDetailColumnView: View {
) )
} else if showsSourceDetails, let source { } else if showsSourceDetails, let source {
SourceDetailView(source: 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 { } else {
Text("Select a world or pack to see details") Text("Select a world or pack to see details")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

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

@ -183,7 +183,7 @@ struct SourceDetailView: View {
} }
switch source.origin { switch source.origin {
case .localFolder: case .localFolder, .javaLocalFolder:
break break
case .connectedDevice(let device, let container): case .connectedDevice(let device, let container):
rows.append(("Connection", device.connection == .network ? "Network" : "USB")) rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
@ -197,19 +197,31 @@ struct SourceDetailView: View {
} }
private var contentRows: [(String, String)] { private var contentRows: [(String, String)] {
[ var rows = [("Total Items", source.items.count.formatted(.number))]
("Total Items", source.items.count.formatted(.number)), let orderedKinds: [(MinecraftContentKind, String)] = [
("Worlds", itemCount(for: .world).formatted(.number)), (.world, "Worlds"),
("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)), (.behaviorPack, "Behavior Packs"),
("Resource Packs", itemCount(for: .resourcePack).formatted(.number)), (.resourcePack, "Resource Packs"),
("Skin Packs", itemCount(for: .skinPack).formatted(.number)), (.dataPack, "Data Packs"),
("World Templates", itemCount(for: .worldTemplate).formatted(.number)) (.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)] { private var locationRows: [(String, String)] {
switch source.origin { switch source.origin {
case .localFolder: case .localFolder, .javaLocalFolder:
return [("Filesystem Path", source.folderURL.path)] return [("Filesystem Path", source.folderURL.path)]
case .connectedDevice(_, let container): case .connectedDevice(_, let container):
var rows: [(String, String)] = [ var rows: [(String, String)] = [
@ -224,7 +236,7 @@ struct SourceDetailView: View {
private var technicalRows: [(String, String)] { private var technicalRows: [(String, String)] {
switch source.origin { switch source.origin {
case .localFolder: case .localFolder, .javaLocalFolder:
return [] return []
case .connectedDevice(let device, let container): case .connectedDevice(let device, let container):
var rows: [(String, String)] = [ var rows: [(String, String)] = [
@ -244,6 +256,8 @@ struct SourceDetailView: View {
switch source.origin { switch source.origin {
case .localFolder: case .localFolder:
return "Local Folder" return "Local Folder"
case .javaLocalFolder:
return "Java Local Folder"
case .connectedDevice: case .connectedDevice:
return "Connected Device" return "Connected Device"
} }
@ -483,6 +497,14 @@ struct SourceDetailView: View {
source.items.filter { $0.contentType == type }.count 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 @ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View { private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {

View File

@ -144,6 +144,9 @@ enum PreviewFixtures {
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1 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.rawItems = source.displayItems
source.logicalPacks = [ source.logicalPacks = [
LogicalPack( LogicalPack(
@ -229,6 +232,9 @@ enum PreviewFixtures {
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1 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.rawItems = source.displayItems
source.indexedItemCount = source.displayItems.count source.indexedItemCount = source.displayItems.count
source.indexedDetailCount = source.displayItems.count source.indexedDetailCount = source.displayItems.count
@ -256,8 +262,12 @@ struct SidebarColumnPreviewContainer: View {
SourcesSidebarView( SourcesSidebarView(
sources: PreviewFixtures.allSources, sources: PreviewFixtures.allSources,
connectedDevices: [], connectedDevices: [],
sourceCandidates: [],
isDiscoveringSourceCandidates: false,
selection: $selection, selection: $selection,
addSourceAction: {}, addSourceAction: {},
discoverSourcesAction: {},
addCandidateSourceAction: { _ in },
addDeviceSourceAction: {}, addDeviceSourceAction: {},
addConnectedDeviceAction: { _ in }, addConnectedDeviceAction: { _ in },
rescanSourceAction: { _ in }, rescanSourceAction: { _ in },
@ -345,6 +355,8 @@ struct ItemDetailColumnPreviewContainer: View {
ItemDetailColumnView( ItemDetailColumnView(
item: PreviewFixtures.featuredWorld, item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource, source: PreviewFixtures.primarySource,
sourceCandidate: nil,
connectedDevice: nil,
showsSourceDetails: false, showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack), behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack), resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -359,7 +371,10 @@ struct ItemDetailColumnPreviewContainer: View {
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
exportAction: {}, exportAction: {},
revealAction: {}, revealAction: {},
shareAction: { _ in } shareAction: { _ in },
addCandidateSourceAction: { _ in },
revealCandidateAction: { _ in },
addConnectedDeviceAction: { _ in }
) )
} }
} }

View File

@ -43,8 +43,10 @@ struct ContentView: View {
} }
var body: some View { 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 resolvedCurrentSource = currentSource
let resolvedCurrentSourceCandidate = currentSourceCandidate
let resolvedCurrentConnectedDevice = currentConnectedDevice
let currentProjectionRequest = ItemCollectionProjectionRequest( let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection, selection: selectedSidebarSelection,
searchText: searchText, searchText: searchText,
@ -74,9 +76,15 @@ struct ContentView: View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
sources: library.sidebarSources, sources: library.sidebarSources,
connectedDevices: library.connectedDevices, connectedDevices: library.sidebarConnectedDevices,
sourceCandidates: library.sourceCandidates,
isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates,
selection: sidebarSelectionBinding, selection: sidebarSelectionBinding,
addSourceAction: pickFolder, addSourceAction: pickFolder,
discoverSourcesAction: {
library.perform(.discoverSourceCandidates)
},
addCandidateSourceAction: addCandidateSource(_:),
addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:), addConnectedDeviceAction: addConnectedDeviceSource(from:),
rescanSourceAction: { source in rescanSourceAction: { source in
@ -116,6 +124,8 @@ struct ContentView: View {
ItemDetailColumnView( ItemDetailColumnView(
item: resolvedCurrentSelectedItem, item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource, source: resolvedCurrentSource,
sourceCandidate: resolvedCurrentSourceCandidate,
connectedDevice: resolvedCurrentConnectedDevice,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection, showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -148,7 +158,10 @@ struct ContentView: View {
} }
shareItem(item, from: anchorView) shareItem(item, from: anchorView)
} },
addCandidateSourceAction: addCandidateSource(_:),
revealCandidateAction: revealCandidateInFinder(_:),
addConnectedDeviceAction: addConnectedDeviceSource(from:)
) )
.frame(minWidth: 450) .frame(minWidth: 450)
} }
@ -183,7 +196,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, _ in .onChange(of: library.sources.map(\.id)) { _, _ in
syncSelection(with: library.visibleSources.map(\.id)) 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)) syncSelection(with: library.visibleSources.map(\.id))
} }
.task(id: currentProjectionRequest) { .task(id: currentProjectionRequest) {
@ -249,6 +262,22 @@ struct ContentView: View {
return library.source(withID: sourceID) 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? { private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else { guard let selectedItemID else {
return nil return nil
@ -331,16 +360,27 @@ struct ContentView: View {
} }
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
return MinecraftContentType.allCases.compactMap { contentType in let orderedKinds: [MinecraftContentKind] = [
guard let count = source.displayItemCountsByType[contentType], count > 0 else { .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 nil
} }
return SidebarFilter( return SidebarFilter(
title: sidebarTitle(for: contentType), title: sidebarTitle(for: contentKind),
iconName: sidebarIcon(for: contentType), iconName: sidebarIcon(for: contentKind),
count: count, count: count,
selection: .contentType(sourceID: source.id, contentType: contentType) selection: .contentKind(sourceID: source.id, contentKind: contentKind)
) )
} }
} }
@ -372,6 +412,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 { private func sidebarIcon(for contentType: MinecraftContentType) -> String {
switch contentType { switch contentType {
case .world: case .world:
@ -387,6 +448,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 @ViewBuilder
private func itemContextMenu(for item: MinecraftContentItem) -> some View { private func itemContextMenu(for item: MinecraftContentItem) -> some View {
Button("Share...") { Button("Share...") {
@ -521,11 +603,25 @@ struct ContentView: View {
} }
for url in panel.urls { for url in panel.urls {
let sourceID = library.addSource(at: url) Task { @MainActor in
selectSourceIfNeeded(sourceID) let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
} }
} }
private func addCandidateSource(_ candidate: SourceCandidate) {
Task {
let sourceID = await library.addSource(candidate: candidate)
selectedSidebarSelection = .source(sourceID: sourceID)
selectedItemID = nil
}
}
private func revealCandidateInFinder(_ candidate: SourceCandidate) {
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool { private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) } let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
@ -543,7 +639,7 @@ struct ContentView: View {
} }
Task { @MainActor in Task { @MainActor in
let sourceID = library.addSource(at: url) let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID) selectSourceIfNeeded(sourceID)
} }
} }
@ -585,8 +681,22 @@ struct ContentView: View {
} }
private func syncSelection(with sourceIDs: [URL]) { private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) { if let selectedSidebarSelection {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) } 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 { } else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .source(sourceID: firstSourceID) self.selectedSidebarSelection = .source(sourceID: firstSourceID)
} }
@ -767,7 +877,7 @@ struct ContentView: View {
} }
private func archiveType(for item: MinecraftContentItem) -> UTType { private func archiveType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data itemActionService.archiveContentType(for: item)
} }
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider { private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {

View File

@ -77,21 +77,31 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate:
return "Source Candidate"
case .connectedDevice:
return "Connected Device"
case .source, .allContent: case .source, .allContent:
return "All Items" return "All Items"
case .contentType(_, let contentType): case .contentType(_, let contentType):
return sidebarTitle(for: contentType) return sidebarTitle(for: contentType)
case .contentKind(_, let contentKind):
return sidebarTitle(for: contentKind)
} }
} }
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String { nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate), .some(.connectedDevice):
return "Search Library"
case .some(.source): case .some(.source):
return "Search \(source?.displayName ?? "Library")" return "Search \(source?.displayName ?? "Library")"
case .some(.allContent): case .some(.allContent):
return "Search All Items" return "Search All Items"
case .some(.contentType(_, let contentType)): case .some(.contentType(_, let contentType)):
return "Search \(sidebarTitle(for: contentType))" return "Search \(sidebarTitle(for: contentType))"
case .some(.contentKind(_, let contentKind)):
return "Search \(sidebarTitle(for: contentKind))"
case .none: case .none:
return "Search Library" return "Search Library"
} }
@ -99,12 +109,18 @@ enum ItemCollectionProjector {
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String { nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate):
return "Source Candidate"
case .some(.connectedDevice):
return "Connected Device"
case .some(.source): case .some(.source):
return "Library" return "Library"
case .some(.allContent): case .some(.allContent):
return "All" return "All"
case .some(.contentType(_, let contentType)): case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType) return sidebarTitle(for: contentType)
case .some(.contentKind(_, let contentKind)):
return sidebarTitle(for: contentKind)
case .none: case .none:
return "Library" return "Library"
} }
@ -116,6 +132,8 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate, .connectedDevice:
return "items"
case .source, .allContent: case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items" return scopedItemCount == 1 ? "item" : "items"
case .contentType(_, let contentType): case .contentType(_, let contentType):
@ -125,6 +143,17 @@ enum ItemCollectionProjector {
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs" 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 { nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines) request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
} }

View File

@ -5,13 +5,18 @@ import SwiftUI
enum SidebarSelection: Hashable, Sendable { enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL) case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case connectedDevice(deviceID: String)
case allContent(sourceID: URL) case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType) case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL { var sourceID: URL? {
switch self { 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 return sourceID
case .sourceCandidate, .connectedDevice:
return nil
} }
} }
} }
@ -24,11 +29,30 @@ struct SidebarFilter: Identifiable, Hashable {
let selection: SidebarSelection 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 { struct SourcesSidebarView: View {
let sources: [MinecraftSource] let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry] let connectedDevices: [ConnectedDeviceSidebarEntry]
let sourceCandidates: [SourceCandidate]
let isDiscoveringSourceCandidates: Bool
@Binding var selection: SidebarSelection? @Binding var selection: SidebarSelection?
let addSourceAction: () -> Void let addSourceAction: () -> Void
let discoverSourcesAction: () -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let addDeviceSourceAction: () -> Void let addDeviceSourceAction: () -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void let rescanSourceAction: (MinecraftSource) -> Void
@ -37,28 +61,45 @@ struct SourcesSidebarView: View {
var body: some View { var body: some View {
List(selection: $selection) { List(selection: $selection) {
if !sources.isEmpty { if !libraryNodes.isEmpty {
Section { Section {
ForEach(sources) { source in OutlineGroup(libraryNodes, children: \.children, content: sidebarNodeRow)
sourceSectionRows(for: source)
}
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Libraries") SidebarSourcesSectionHeaderView(title: "Libraries")
} }
} }
if !connectedDevices.isEmpty { if !deviceNodes.isEmpty {
Section { Section {
ForEach(connectedDevices) { entry in OutlineGroup(deviceNodes, children: \.children, content: sidebarNodeRow)
connectedDeviceSectionRows(for: entry)
}
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Available Devices") SidebarSourcesSectionHeaderView(title: "Available Devices")
} }
} }
if !candidateNodes.isEmpty {
Section {
OutlineGroup(candidateNodes, children: \.children, content: sidebarNodeRow)
} header: {
SidebarSourcesSectionHeaderView(title: "Found Sources")
}
}
} }
.listStyle(.sidebar) .listStyle(.sidebar)
.toolbar { .toolbar {
ToolbarItem {
Button(action: discoverSourcesAction) {
if isDiscoveringSourceCandidates {
ProgressView()
.appActivityIndicatorStyle(.small)
} else {
Image(systemName: "magnifyingglass")
}
}
.disabled(isDiscoveringSourceCandidates)
.help("Find Minecraft Sources")
}
ToolbarItem { ToolbarItem {
Button(action: addSourceAction) { Button(action: addSourceAction) {
Image(systemName: "folder.badge.plus") Image(systemName: "folder.badge.plus")
@ -75,53 +116,138 @@ struct SourcesSidebarView: View {
} }
} }
@ViewBuilder private var libraryNodes: [SidebarNode] {
private func sourceSectionRows(for source: MinecraftSource) -> some View { sources.map { source in
let sourceFilters = filters(source) let childNodes = filters(source).map { filter in
SidebarNode(
SourceHeaderRow( id: filter.selection,
source: source, row: .filter(filter),
onSelect: { children: nil
selection = .source(sourceID: source.id) )
}
)
.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)
}
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
} }
ForEach(sourceFilters) { filter in return SidebarNode(
SidebarFilterRow(filter: filter, isIndented: true) id: .source(sourceID: source.id),
.tag(filter.selection as SidebarSelection?) 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 @ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View { private func sidebarNodeRow(_ node: SidebarNode) -> some View {
ConnectedDeviceRow( switch node.row {
entry: entry, case .source(let source):
addAction: entry.hasMinecraftContainer ? { SourceHeaderRow(source: source)
addConnectedDeviceAction(entry) .tag(node.selection as SidebarSelection?)
} : nil .listRowSeparator(.hidden)
) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden) .contextMenu {
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8)) 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)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
case .sourceCandidate(let candidate):
SourceCandidateRow(
candidate: candidate,
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
}
private struct SourceCandidateRow: View {
let candidate: SourceCandidate
let addAction: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: symbolName)
.foregroundStyle(.secondary)
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(candidate.displayName)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
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 { private struct SidebarFilterRow: View {
let filter: SidebarFilter let filter: SidebarFilter
let isIndented: Bool
var body: some View { var body: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
@ -136,7 +262,6 @@ private struct SidebarFilterRow: View {
Text(filter.count, format: .number) Text(filter.count, format: .number)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.leading, isIndented ? 16 : 0)
} }
} }
@ -150,7 +275,6 @@ private struct SidebarSourcesSectionHeaderView: View {
private struct SourceHeaderRow: View { private struct SourceHeaderRow: View {
let source: MinecraftSource let source: MinecraftSource
let onSelect: () -> Void
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@ -177,10 +301,8 @@ private struct SourceHeaderRow: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 5) .padding(.horizontal, 8)
.padding(.vertical, 6) .padding(.vertical, 5)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
} }
private var connection: DeviceConnection? { private var connection: DeviceConnection? {
@ -197,7 +319,7 @@ private struct SourceHeaderRow: View {
private var headerSymbolName: String { private var headerSymbolName: String {
switch source.origin { switch source.origin {
case .localFolder: case .localFolder, .javaLocalFolder:
return "folder" return "folder"
case .connectedDevice: case .connectedDevice:
return "iphone.gen3" return "iphone.gen3"

View File

@ -13,6 +13,9 @@ struct World_Manager_for_MinecraftTests {
@Test func sourceOriginsExposeOutboundCapabilities() async throws { @Test func sourceOriginsExposeOutboundCapabilities() async throws {
let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local")) let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local"))
#expect(localSource.capabilities == .localFolder) #expect(localSource.capabilities == .localFolder)
#expect(localSource.edition == .bedrock)
#expect(localSource.providerID == LocalFolderSourceAccess().accessorIdentifier)
#expect(localSource.accessStatus.mode == .localFileSystem)
let device = ConnectedDevice( let device = ConnectedDevice(
udid: "device", udid: "device",
@ -35,6 +38,540 @@ struct World_Manager_for_MinecraftTests {
) )
#expect(deviceSource.capabilities == .connectedDevice) #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"
displayName = "Example Java Mod"
logoFile = "icon.png"
description = "A test mod."
""".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
}
}
""".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)
} 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)
} 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 { @Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws {
@ -951,6 +1488,106 @@ struct World_Manager_for_MinecraftTests {
#expect(restored[0].lastScanDate == legacyRecord.lastScanDate) #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 { @Test func connectedDeviceSourceFactoryCreatesStableSyntheticIdentifier() async throws {
let device = ConnectedDevice( let device = ConnectedDevice(
udid: "00008110-001234560E90001E", udid: "00008110-001234560E90001E",

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.