First refactor

This commit is contained in:
John Burwell 2026-06-01 20:50:52 -05:00
parent 3a97ae0d53
commit 14d9048b57
24 changed files with 1462 additions and 88 deletions

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 = []
@ -117,6 +125,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,61 @@ 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 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 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

@ -46,7 +46,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 +209,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 +271,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 +280,10 @@ 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 uniqueArchiveURL( nonisolated private static func uniqueArchiveURL(
in directoryURL: URL, in directoryURL: URL,
baseName: String, baseName: String,

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
@ -162,7 +164,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 +324,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 +582,177 @@ private actor PackReferenceIndexStore {
} }
} }
enum JavaContentScanner {
nonisolated static func discoverItems(
in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
) throws -> [MinecraftContentItem] {
let fileManager = FileManager.default
var discoveredItems: [MinecraftContentItem] = []
let savesRootURL = existingDirectory(
named: "saves",
in: searchRootURL,
fileManager: fileManager
) ?? searchRootURL
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
discoveredItems.append(contentsOf: worldItems)
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: searchRootURL, fileManager: fileManager) {
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
discoveredItems.append(contentsOf: resourcePackItems)
}
discoveredItems.sort(by: WorldScanner.sortItems)
discoveredItems.forEach(onDiscovered)
return discoveredItems
}
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
var enrichedItem = item
enrichedItem.displayName = displayName(for: item)
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 = WorldScanner.folderSize(at: item.folderURL, fileManager: .default)
sizedItem.sizeLoaded = true
return sizedItem
}
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
let fileManager = FileManager.default
let candidateRoots = [
existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager),
existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager)
]
return candidateRoots.compactMap { collectionURL in
guard let collectionURL else {
return nil
}
return collectionSnapshot(for: collectionURL, fileManager: fileManager)
}
}
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] {
let directories = try WorldScanner.immediateChildDirectories(of: resourcePacksURL, fileManager: fileManager)
return directories.compactMap { packURL in
guard fileManager.fileExists(atPath: packURL.appendingPathComponent("pack.mcmeta").path) else {
return nil
}
return MinecraftContentItem(
folderURL: packURL,
folderName: packURL.lastPathComponent,
contentType: .resourcePack,
sourceEdition: .java,
contentKind: .resourcePack,
platformType: .java(.resourcePack),
collectionRootURL: resourcePacksURL,
capabilities: .java(contentType: .resourcePack),
platformMetadata: .java(JavaContentMetadata())
)
}
}
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 collectionSnapshot(
for collectionURL: URL,
fileManager: FileManager
) -> CollectionSnapshot? {
guard fileManager.fileExists(atPath: collectionURL.path) else {
return nil
}
let children = (try? fileManager.contentsOfDirectory(
at: collectionURL,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
)) ?? []
let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in
guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
return nil
}
let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
return (childURL.lastPathComponent, modifiedDate)
}.sorted {
$0.name.localizedStandardCompare($1.name) == .orderedAscending
}
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
let childFingerprint = childDirectorySnapshots.map { child in
[
child.name,
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
].joined(separator: "@")
}.joined(separator: "|")
return CollectionSnapshot(
folderName: collectionURL.lastPathComponent,
modifiedDate: modifiedDate,
childDirectoryCount: childDirectorySnapshots.count,
fingerprint: [
collectionURL.lastPathComponent,
String(childDirectorySnapshots.count),
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
childFingerprint
].joined(separator: "::")
)
}
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

@ -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

@ -147,6 +147,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
} }
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
source.providerID = source.accessDescriptor.accessorIdentifier
source.capabilities = source.origin.defaultCapabilities source.capabilities = source.origin.defaultCapabilities
} }
startScan(for: normalizedURL, mode: .fullScan) startScan(for: normalizedURL, mode: .fullScan)
@ -171,6 +172,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
updateSource(source.id) { existingSource in updateSource(source.id) { 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.origin.defaultEdition
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,6 +187,9 @@ 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 = resolvedSource.origin.defaultEdition
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 }
@ -320,6 +327,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 +370,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

View File

@ -56,6 +56,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

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,11 +102,36 @@ 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
} }
switch event {
case .accessStatusChanged(let accessStatus):
host.updateSource(sourceID) { source in
source.accessStatus = accessStatus
source.availability = accessStatus.availability
}
continue
case .stageUpdated(let stage):
host.updateSource(sourceID) { source in
source.scanStatus = stage.detail ?? stage.title
}
continue
case .warning(let warning):
host.updateSource(sourceID) { source in
source.scanDiagnostic = warning.detail ?? warning.message
}
continue
case .inspected(let inspectedItem):
if let snapshot = await index.applyEnrichedItem(inspectedItem) {
await MainActor.run {
host.applySnapshot(snapshot, to: sourceID)
}
}
continue
case .discovered(let item):
discoveredCount += 1 discoveredCount += 1
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
let itemForIndex: MinecraftContentItem let itemForIndex: MinecraftContentItem
@ -147,6 +158,7 @@ enum SourceScanExecutor {
await enrichmentQueue.enqueue(item) await enrichmentQueue.enqueue(item)
} }
} }
}
if mode == .reconcile, source.origin.kind == .connectedDevice { if mode == .reconcile, source.origin.kind == .connectedDevice {
let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in
@ -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 {
guard case .connectedDevice(let expectedDevice, _) = source.origin else { await accessStatus(for: source).availability
return .unavailable
} }
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
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

@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable {
protocol SourceAccessMethod: Sendable { protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
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 +19,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]
@ -42,8 +47,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 +70,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 +191,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]) {
@ -164,6 +233,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 +248,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,7 +3,9 @@
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() {}
@ -18,9 +20,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 +41,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 +208,117 @@ 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"
nonisolated init() {}
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
if case .javaLocalFolder(let bookmarkData) = source.origin,
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
guard case .javaLocalFolder(let bookmarkData) = source.origin else {
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 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
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

@ -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"))
@ -209,7 +209,7 @@ struct SourceDetailView: View {
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 +224,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 +244,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"
} }

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

View File

@ -331,16 +331,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 +383,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 +419,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...") {
@ -767,7 +820,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

@ -81,6 +81,8 @@ enum ItemCollectionProjector {
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)
} }
} }
@ -92,6 +94,8 @@ enum ItemCollectionProjector {
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"
} }
@ -105,6 +109,8 @@ enum ItemCollectionProjector {
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"
} }
@ -125,6 +131,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 +160,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

@ -7,10 +7,11 @@ enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL) case source(sourceID: URL)
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
} }
} }
@ -197,7 +198,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,172 @@ 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 worldURL = rootURL.appendingPathComponent("saves/JavaWorld", isDirectory: true)
let packURL = rootURL.appendingPathComponent("resourcepacks/JavaPack", isDirectory: true)
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
)
let source = MinecraftSource(
folderURL: rootURL,
origin: .javaLocalFolder(bookmarkData: nil)
)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
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 == 2)
#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(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] == 1)
indexedSource.rawItems = enrichedItems
let snapshot = SourceScanPolicy.buildSnapshot(for: indexedSource, scanRootURL: rootURL)
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("saves"))
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("resourcepacks"))
} }
@Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws { @Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws {

View File

@ -0,0 +1,196 @@
# 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
### 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.
## Responsibilities
### Engine Owns
- Provider registration/routing.
- 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.
- 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.