From 14d9048b57b1b134bbaabba3c60246ad8d3cd3d8 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 1 Jun 2026 20:50:52 -0500 Subject: [PATCH] First refactor --- .../Models/Content/MinecraftContentItem.swift | 243 +++++++++++++++++- .../Models/Sources/MinecraftSource.swift | 13 + .../Models/Sources/SourceOrigin.swift | 41 ++- .../Models/Sources/SourceRecord.swift | 55 ++++ .../Export/ContentItemActionService.swift | 2 +- .../Export/ContentPackageExporter.swift | 10 +- .../AppSupport/Scanning/WorldScanner.swift | 183 ++++++++++++- .../MinecraftContentMetadataReader.swift | 4 +- .../Services/Sources/Core/SourceLibrary.swift | 9 + .../Persistence/SourceRestoration.swift | 3 + .../Sources/Scanning/SourceContentIndex.swift | 10 +- .../Scanning/SourceScanExecution.swift | 104 +++++--- .../Sources/Scanning/SourceScanning.swift | 19 +- .../AppleMobileDeviceSourceAccess.swift | 54 +++- .../Core/SourceAccessCoordinator.swift | 86 ++++++- .../LocalFolder/LocalFolderSourceAccess.swift | 135 +++++++++- .../UI/Detail/SourceDetailView.swift | 8 +- .../UI/Preview/PreviewFixtures.swift | 6 + .../UI/Root/ContentView.swift | 65 ++++- .../UI/Root/ItemCollectionProjection.swift | 38 +++ .../UI/Sidebar/SidebarColumnViews.swift | 5 +- .../World_Manager_for_MinecraftTests.swift | 169 ++++++++++++ docs/provider-architecture-design.md | 196 ++++++++++++++ docs/provider-refactor-migration-plan.md | 92 +++++++ 24 files changed, 1462 insertions(+), 88 deletions(-) create mode 100644 docs/provider-architecture-design.md create mode 100644 docs/provider-refactor-migration-plan.md diff --git a/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift b/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift index ae80cc6..8c6daa2 100644 --- a/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift @@ -3,6 +3,47 @@ import Foundation +typealias PlatformProviderID = String + +nonisolated enum MinecraftEdition: String, CaseIterable, Hashable, Sendable, Codable { + case bedrock + case java +} + +nonisolated enum MinecraftContentKind: String, CaseIterable, Hashable, Sendable, Codable { + case world + case behaviorPack + case resourcePack + case dataPack + case skinPack + case worldTemplate + case shaderPack + case mod +} + +nonisolated enum JavaContentType: String, CaseIterable, Hashable, Sendable, Codable { + case world = "Java World" + case resourcePack = "Java Resource Pack" + case dataPack = "Java Data Pack" + case shaderPack = "Java Shader Pack" + case mod = "Java Mod" + + nonisolated var kind: MinecraftContentKind { + switch self { + case .world: + return .world + case .resourcePack: + return .resourcePack + case .dataPack: + return .dataPack + case .shaderPack: + return .shaderPack + case .mod: + return .mod + } + } +} + nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable { case world = "World" case behaviorPack = "Behavior Pack" @@ -25,6 +66,21 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, } } + nonisolated var kind: MinecraftContentKind { + switch self { + case .world: + return .world + case .behaviorPack: + return .behaviorPack + case .resourcePack: + return .resourcePack + case .skinPack: + return .skinPack + case .worldTemplate: + return .worldTemplate + } + } + nonisolated var archiveExtension: String { switch self { case .world: @@ -52,6 +108,71 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, } } +nonisolated enum MinecraftPlatformContentType: Hashable, Sendable, Codable { + case bedrock(MinecraftContentType) + case java(JavaContentType) + + nonisolated var edition: MinecraftEdition { + switch self { + case .bedrock: + return .bedrock + case .java: + return .java + } + } + + nonisolated var kind: MinecraftContentKind { + switch self { + case .bedrock(let contentType): + return contentType.kind + case .java(let contentType): + return contentType.kind + } + } + + nonisolated var displayName: String { + switch self { + case .bedrock(let contentType): + return contentType.rawValue + case .java(let contentType): + return contentType.rawValue + } + } +} + +nonisolated struct ContentItemCapabilities: Hashable, Sendable, Codable { + var canRevealNativeContent: Bool + var canExportPortablePackage: Bool + var canShare: Bool + var portablePackageExtension: String? + + nonisolated static func bedrock(contentType: MinecraftContentType) -> ContentItemCapabilities { + ContentItemCapabilities( + canRevealNativeContent: true, + canExportPortablePackage: true, + canShare: true, + portablePackageExtension: contentType.archiveExtension + ) + } + + nonisolated static func java(contentType: JavaContentType) -> ContentItemCapabilities { + let extensionName: String? + switch contentType { + case .world, .resourcePack, .dataPack, .shaderPack: + extensionName = "zip" + case .mod: + extensionName = "jar" + } + + return ContentItemCapabilities( + canRevealNativeContent: true, + canExportPortablePackage: true, + canShare: true, + portablePackageExtension: extensionName + ) + } +} + nonisolated enum PackSource: String, Hashable, Sendable, Codable { case referencedByWorld case embeddedInWorld @@ -113,11 +234,77 @@ nonisolated struct PackMetadataDetails: Hashable, Sendable, Codable { var minimumEngineVersion: String? } +nonisolated enum PlatformContentMetadata: Hashable, Sendable, Codable { + case bedrock(BedrockContentMetadata) + case java(JavaContentMetadata) + case none +} + +nonisolated struct BedrockContentMetadata: Hashable, Sendable, Codable { + var world: WorldMetadata? + var packUUID: String? + var packVersion: String? + var packDetails: PackMetadataDetails? + var packReferences: [ContentPackReference] + + nonisolated init( + world: WorldMetadata? = nil, + packUUID: String? = nil, + packVersion: String? = nil, + packDetails: PackMetadataDetails? = nil, + packReferences: [ContentPackReference] = [] + ) { + self.world = world + self.packUUID = packUUID?.lowercased() + self.packVersion = packVersion + self.packDetails = packDetails + self.packReferences = packReferences + } +} + +nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable { + var world: JavaWorldMetadata? + var pack: JavaPackMetadata? + var 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 { let id: URL let folderURL: URL let folderName: String let contentType: MinecraftContentType + let sourceEdition: MinecraftEdition + let contentKind: MinecraftContentKind + let platformType: MinecraftPlatformContentType let collectionRootURL: URL var displayName: String var iconURL: URL? @@ -125,11 +312,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab var lastPlayedDate: Date? var modifiedDate: Date? var sizeBytes: Int64? - var packUUID: String? - var packVersion: String? - var packMetadataDetails: PackMetadataDetails? - var packReferences: [ContentPackReference] - var worldMetadata: WorldMetadata? + var capabilities: ContentItemCapabilities + var platformMetadata: PlatformContentMetadata + var packUUID: String? { + didSet { syncBedrockMetadataFromCompatibilityFields() } + } + var packVersion: String? { + didSet { syncBedrockMetadataFromCompatibilityFields() } + } + var packMetadataDetails: PackMetadataDetails? { + didSet { syncBedrockMetadataFromCompatibilityFields() } + } + var packReferences: [ContentPackReference] { + didSet { syncBedrockMetadataFromCompatibilityFields() } + } + var worldMetadata: WorldMetadata? { + didSet { syncBedrockMetadataFromCompatibilityFields() } + } var metadataLoaded: Bool var previewLoaded: Bool var sizeLoaded: Bool @@ -138,6 +337,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab folderURL: URL, folderName: String, contentType: MinecraftContentType, + sourceEdition: MinecraftEdition? = nil, + contentKind: MinecraftContentKind? = nil, + platformType: MinecraftPlatformContentType? = nil, collectionRootURL: URL, displayName: String? = nil, iconURL: URL? = nil, @@ -145,6 +347,8 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab lastPlayedDate: Date? = nil, modifiedDate: Date? = nil, sizeBytes: Int64? = nil, + capabilities: ContentItemCapabilities? = nil, + platformMetadata: PlatformContentMetadata? = nil, packUUID: String? = nil, packVersion: String? = nil, packMetadataDetails: PackMetadataDetails? = nil, @@ -158,6 +362,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab self.folderURL = folderURL self.folderName = folderName self.contentType = contentType + self.sourceEdition = sourceEdition ?? .bedrock + self.contentKind = contentKind ?? contentType.kind + self.platformType = platformType ?? .bedrock(contentType) self.collectionRootURL = collectionRootURL self.displayName = displayName ?? folderName self.iconURL = iconURL @@ -165,6 +372,16 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab self.lastPlayedDate = lastPlayedDate self.modifiedDate = modifiedDate self.sizeBytes = sizeBytes + self.capabilities = capabilities ?? .bedrock(contentType: contentType) + self.platformMetadata = platformMetadata ?? .bedrock( + BedrockContentMetadata( + world: worldMetadata, + packUUID: packUUID, + packVersion: packVersion, + packDetails: packMetadataDetails, + packReferences: packReferences + ) + ) self.packUUID = packUUID?.lowercased() self.packVersion = packVersion self.packMetadataDetails = packMetadataDetails @@ -175,6 +392,22 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab self.sizeLoaded = sizeLoaded } + nonisolated mutating private func syncBedrockMetadataFromCompatibilityFields() { + guard sourceEdition == .bedrock else { + return + } + + platformMetadata = .bedrock( + BedrockContentMetadata( + world: worldMetadata, + packUUID: packUUID, + packVersion: packVersion, + packDetails: packMetadataDetails, + packReferences: packReferences + ) + ) + } + nonisolated var folderID: String { folderName } diff --git a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift index aed7341..8472461 100644 --- a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift @@ -6,14 +6,18 @@ import Foundation nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL + var edition: MinecraftEdition + var providerID: PlatformProviderID var origin: MinecraftSourceOrigin var accessDescriptor: SourceAccessDescriptor + var accessStatus: SourceAccessStatus var availability: SourceAvailability var capabilities: SourceCapabilities var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] var displayItemCountsByType: [MinecraftContentType: Int] + var displayItemCountsByKind: [MinecraftContentKind: Int] var rawItems: [MinecraftContentItem] var logicalPacks: [LogicalPack] var logicalWorlds: [LogicalWorld] @@ -47,18 +51,22 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable { let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData) self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL) self.folderURL = normalizedFolderURL + self.edition = resolvedOrigin.defaultEdition + self.providerID = resolvedOrigin.defaultAccessorIdentifier self.origin = resolvedOrigin self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor( accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier, kind: resolvedOrigin.kind, refreshStrategy: resolvedOrigin.defaultRefreshStrategy ) + self.accessStatus = resolvedOrigin.defaultAccessStatus(displayName: normalizedFolderURL.lastPathComponent) self.availability = availability self.capabilities = resolvedOrigin.defaultCapabilities self.bookmarkData = bookmarkData self.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] self.displayItemCountsByType = [:] + self.displayItemCountsByKind = [:] self.rawItems = [] self.logicalPacks = [] self.logicalWorlds = [] @@ -117,6 +125,11 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable { return [] } return items(for: contentType) + case .contentKind(let sourceID, let contentKind): + guard sourceID == id else { + return [] + } + return displayItems.filter { $0.contentKind == contentKind } } } diff --git a/World Manager for Minecraft/Models/Sources/SourceOrigin.swift b/World Manager for Minecraft/Models/Sources/SourceOrigin.swift index e05cc13..b8061c3 100644 --- a/World Manager for Minecraft/Models/Sources/SourceOrigin.swift +++ b/World Manager for Minecraft/Models/Sources/SourceOrigin.swift @@ -45,20 +45,32 @@ nonisolated enum DeviceContainerAccessMode: String, Hashable, Sendable, Codable nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable { case localFolder(bookmarkData: Data?) + case javaLocalFolder(bookmarkData: Data?) case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer) nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier { switch self { case .localFolder: return LocalFolderSourceAccess().accessorIdentifier + case .javaLocalFolder: + return JavaLocalFolderSourceAccess().accessorIdentifier case .connectedDevice: return AppleMobileDeviceSourceAccess().accessorIdentifier } } + nonisolated var defaultEdition: MinecraftEdition { + switch self { + case .localFolder, .connectedDevice: + return .bedrock + case .javaLocalFolder: + return .java + } + } + nonisolated var kind: MinecraftSourceKind { switch self { - case .localFolder: + case .localFolder, .javaLocalFolder: return .localFolder case .connectedDevice: return .connectedDevice @@ -67,7 +79,7 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable { nonisolated var defaultRefreshStrategy: SourceRefreshStrategy { switch self { - case .localFolder: + case .localFolder, .javaLocalFolder: return .eagerFullScan case .connectedDevice: return .staged @@ -76,12 +88,35 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable { nonisolated var defaultCapabilities: SourceCapabilities { switch self { - case .localFolder: + case .localFolder, .javaLocalFolder: return .localFolder case .connectedDevice: return .connectedDevice } } + + nonisolated func defaultAccessStatus(displayName: String) -> SourceAccessStatus { + switch self { + case .localFolder(let bookmarkData), .javaLocalFolder(let bookmarkData): + return SourceAccessStatus( + availability: .unknown, + mode: bookmarkData == nil ? .localFileSystem : .securityScopedLocalFolder, + displayName: displayName, + iconSystemName: "folder", + statusText: nil, + warningText: nil + ) + case .connectedDevice(let device, _): + return SourceAccessStatus( + availability: .unknown, + mode: device.connection == .usb ? .usbDevice : .networkDevice, + displayName: displayName, + iconSystemName: "iphone.gen3", + statusText: nil, + warningText: nil + ) + } + } } nonisolated enum MinecraftSourceKind: String, Hashable, Sendable, Codable { diff --git a/World Manager for Minecraft/Models/Sources/SourceRecord.swift b/World Manager for Minecraft/Models/Sources/SourceRecord.swift index af81870..024f6d3 100644 --- a/World Manager for Minecraft/Models/Sources/SourceRecord.swift +++ b/World Manager for Minecraft/Models/Sources/SourceRecord.swift @@ -24,6 +24,61 @@ nonisolated struct SourceAccessDescriptor: Hashable, Sendable, Codable { var refreshStrategy: SourceRefreshStrategy } +nonisolated enum SourceAccessMode: String, Hashable, Sendable, Codable { + case localFileSystem + case securityScopedLocalFolder + case usbDevice + case networkDevice + case archive + case unknown +} + +nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable { + var availability: SourceAvailability + var mode: SourceAccessMode + var displayName: String + var iconSystemName: String + var statusText: String? + var warningText: String? +} + +nonisolated enum 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 { let id: URL var displayName: String diff --git a/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift b/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift index 6ba5dd4..ffc0d55 100644 --- a/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift +++ b/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift @@ -16,7 +16,7 @@ struct ContentItemActionService: Sendable { } nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType { - UTType(filenameExtension: item.contentType.archiveExtension) ?? .data + UTType(filenameExtension: item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension) ?? .data } nonisolated func persistExternalRepresentation( diff --git a/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift b/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift index cbe3997..ceb039f 100644 --- a/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift @@ -46,7 +46,7 @@ enum ContentPackageExporter { } nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String { - "\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)" + "\(suggestedBaseFilename(for: item)).\(archiveExtension(for: item))" } nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL { @@ -209,7 +209,7 @@ enum ContentPackageExporter { return requestDirectoryURL .appendingPathComponent(suggestedBaseFilename(for: item)) - .appendingPathExtension(item.contentType.archiveExtension) + .appendingPathExtension(archiveExtension(for: item)) } nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String { @@ -271,7 +271,7 @@ enum ContentPackageExporter { nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL { let normalizedDestinationURL = destinationURL.standardizedFileURL - let requiredExtension = item.contentType.archiveExtension + let requiredExtension = archiveExtension(for: item) if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension { return normalizedDestinationURL @@ -280,6 +280,10 @@ enum ContentPackageExporter { return normalizedDestinationURL.appendingPathExtension(requiredExtension) } + nonisolated private static func archiveExtension(for item: MinecraftContentItem) -> String { + item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension + } + nonisolated private static func uniqueArchiveURL( in directoryURL: URL, baseName: String, diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift index b11b5c8..afdeee2 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift @@ -3,7 +3,9 @@ import Foundation -enum WorldScanner { +typealias WorldScanner = BedrockContentScanner + +enum BedrockContentScanner { nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem { let fileManager = FileManager.default var sizedItem = item @@ -162,7 +164,7 @@ enum WorldScanner { ? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager) : nil enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata) - enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) + enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL) if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) { enrichedItem.packUUID = manifestMetadata.uuid enrichedItem.packVersion = manifestMetadata.version @@ -322,11 +324,11 @@ enum WorldScanner { return worldMetadata?.lastPlayedDate } - nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? { + nonisolated fileprivate static func modifiedDate(for directoryURL: URL) -> Date? { try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } - nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { + nonisolated fileprivate static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { guard let enumerator = fileManager.enumerator( at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], @@ -580,4 +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() diff --git a/World Manager for Minecraft/Services/ArchiveInspection/MinecraftContentMetadataReader.swift b/World Manager for Minecraft/Services/ArchiveInspection/MinecraftContentMetadataReader.swift index 9dfe6bf..7c25c55 100644 --- a/World Manager for Minecraft/Services/ArchiveInspection/MinecraftContentMetadataReader.swift +++ b/World Manager for Minecraft/Services/ArchiveInspection/MinecraftContentMetadataReader.swift @@ -10,7 +10,9 @@ struct MinecraftManifestMetadata: Sendable, Hashable { let minimumEngineVersion: String? } -enum MinecraftContentMetadataReader { +typealias MinecraftContentMetadataReader = BedrockContentMetadataReader + +enum BedrockContentMetadataReader { nonisolated static func displayName( for directoryURL: URL, contentType: MinecraftContentType, diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 2f73606..4dd4f01 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -147,6 +147,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer source.bookmarkData = bookmarkData } source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) + source.providerID = source.accessDescriptor.accessorIdentifier source.capabilities = source.origin.defaultCapabilities } startScan(for: normalizedURL, mode: .fullScan) @@ -171,6 +172,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer updateSource(source.id) { existingSource in existingSource.origin = source.origin 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.capabilities = source.capabilities if existingSource.bookmarkData == nil { @@ -183,6 +187,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } else { var resolvedSource = source 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 sources.append(resolvedSource) sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } @@ -320,6 +327,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer source.worldPackRelationships = index.worldPackRelationships source.displayItems = index.displayItems source.displayItemCountsByType = index.displayItemCountsByType + source.displayItemCountsByKind = index.displayItemCountsByKind } } @@ -362,6 +370,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer updateSource(sourceID) { source in source.displayItems = snapshot.displayItems source.displayItemCountsByType = snapshot.displayItemCountsByType + source.displayItemCountsByKind = snapshot.displayItemCountsByKind source.rawItems = snapshot.rawItems source.logicalPacks = snapshot.logicalPacks source.logicalWorlds = snapshot.logicalWorlds diff --git a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift index 00498b5..dcaca61 100644 --- a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift +++ b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift @@ -56,6 +56,9 @@ enum SourceRestoration { source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in counts[item.contentType, default: 0] += 1 } + source.displayItemCountsByKind = items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in + counts[item.contentKind, default: 0] += 1 + } source.indexedItemCount = items.count source.indexedDetailCount = items.filter(\.metadataLoaded).count source.previewLoadedCount = items.filter(\.previewLoaded).count diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift index 1467f91..7917f7e 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift @@ -11,6 +11,7 @@ struct SourceContentIndex { let worldPackRelationships: [WorldPackRelationship] let displayItems: [MinecraftContentItem] let displayItemCountsByType: [MinecraftContentType: Int] + let displayItemCountsByKind: [MinecraftContentKind: Int] } enum SourceContentIndexer { @@ -171,7 +172,8 @@ enum SourceContentIndexer { packInstances: sortedPackInstances, worldPackRelationships: worldRelationships, displayItems: displayItems, - displayItemCountsByType: displayItemCounts(for: displayItems) + displayItemCountsByType: displayItemCounts(for: displayItems), + displayItemCountsByKind: displayItemKindCounts(for: displayItems) ) } @@ -219,6 +221,12 @@ enum SourceContentIndexer { } } + private static func displayItemKindCounts(for items: [MinecraftContentItem]) -> [MinecraftContentKind: Int] { + items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in + counts[item.contentKind, default: 0] += 1 + } + } + private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { let candidateEmbedded = isEmbeddedWorldPack(candidate) let existingEmbedded = isEmbeddedWorldPack(existing) diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift index 327f359..ba6d045 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift @@ -50,9 +50,10 @@ enum SourceScanExecutor { host.updateSource(sourceID) { source in source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) } - let currentAvailability = await sourceAccessMethod.availability(for: source) + let currentAccessStatus = await sourceAccessMethod.accessStatus(for: source) host.updateSource(sourceID) { source in - source.availability = currentAvailability + source.accessStatus = currentAccessStatus + source.availability = currentAccessStatus.availability } let scanContextURL = source.folderURL @@ -89,22 +90,7 @@ enum SourceScanExecutor { } } } - let discoveryStream = AsyncThrowingStream { continuation in - let discoveryTask = Task.detached(priority: .userInitiated) { - do { - try await sourceAccessMethod.discoverItems(for: source, mode: mode) { item in - continuation.yield(item) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - discoveryTask.cancel() - } - } + let providerEventStream = sourceAccessMethod.scanEvents(for: source, mode: mode) let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) }) let previousSnapshotByItemID = Dictionary( @@ -116,35 +102,61 @@ enum SourceScanExecutor { var discoveredCollectionNames = Set() let discoveryStartTime = Date() - for try await item in discoveryStream { + for try await event in providerEventStream { guard !Task.isCancelled else { break } - discoveredCount += 1 - discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) - let itemForIndex: MinecraftContentItem - if shouldReconcileFromCache, - let cachedItem = previousItemsByID[item.id], - SourceScanPolicy.shouldReuseCachedItem( - cachedItem, - forDiscoveredItem: item, - source: source, - previousSnapshot: previousSnapshotByItemID[item.id] - ) { - itemForIndex = cachedItem - } else { - itemForIndex = item - } + switch event { + case .accessStatusChanged(let accessStatus): + host.updateSource(sourceID) { source in + source.accessStatus = accessStatus + source.availability = accessStatus.availability + } + continue + case .stageUpdated(let stage): + host.updateSource(sourceID) { source in + source.scanStatus = stage.detail ?? stage.title + } + continue + case .warning(let warning): + host.updateSource(sourceID) { source in + source.scanDiagnostic = warning.detail ?? warning.message + } + continue + case .inspected(let inspectedItem): + if let snapshot = await index.applyEnrichedItem(inspectedItem) { + await MainActor.run { + host.applySnapshot(snapshot, to: sourceID) + } + } + continue + case .discovered(let item): + discoveredCount += 1 + discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) + let itemForIndex: MinecraftContentItem + if shouldReconcileFromCache, + let cachedItem = previousItemsByID[item.id], + SourceScanPolicy.shouldReuseCachedItem( + cachedItem, + forDiscoveredItem: item, + source: source, + previousSnapshot: previousSnapshotByItemID[item.id] + ) { + itemForIndex = cachedItem + } else { + itemForIndex = item + } - if let snapshot = await index.addDiscoveredItem( - itemForIndex, - discoveredCount: discoveredCount - ) { - host.applySnapshot(snapshot, to: sourceID) - } - if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { - await enrichmentQueue.enqueue(item) + if let snapshot = await index.addDiscoveredItem( + itemForIndex, + discoveredCount: discoveredCount + ) { + host.applySnapshot(snapshot, to: sourceID) + } + if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { + await enrichmentQueue.enqueue(item) + } } } @@ -464,6 +476,7 @@ private actor EnrichmentWorkQueue { struct SourceIndexSnapshot { let displayItems: [MinecraftContentItem] let displayItemCountsByType: [MinecraftContentType: Int] + let displayItemCountsByKind: [MinecraftContentKind: Int] let rawItems: [MinecraftContentItem] let logicalPacks: [LogicalPack] let logicalWorlds: [LogicalWorld] @@ -631,6 +644,9 @@ private actor SourceIndexActor { let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in counts[item.contentType, default: 0] += 1 } + let displayItemCountsByKind = dedupedDisplayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in + counts[item.contentKind, default: 0] += 1 + } let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) @@ -652,6 +668,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, displayItemCountsByType: displayItemCountsByType, + displayItemCountsByKind: displayItemCountsByKind, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -680,6 +697,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, displayItemCountsByType: displayItemCountsByType, + displayItemCountsByKind: displayItemCountsByKind, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -712,6 +730,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, displayItemCountsByType: displayItemCountsByType, + displayItemCountsByKind: displayItemCountsByKind, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -824,6 +843,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, displayItemCountsByType: displayItemCountsByType, + displayItemCountsByKind: displayItemCountsByKind, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: logicalWorlds, diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift index a65b94c..013acba 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift @@ -6,9 +6,9 @@ import Foundation enum SourceScanPolicy { static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String { switch (source.origin, mode) { - case (.localFolder, .fullScan): + case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan): return "Preparing folder scan..." - case (.localFolder, .reconcile): + case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile): return "Preparing cached library refresh..." case (.connectedDevice, .fullScan): return "Connecting to device and discovering Minecraft items..." @@ -19,9 +19,9 @@ enum SourceScanPolicy { static func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String { switch (source.origin, mode) { - case (.localFolder, .fullScan): + case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan): return "Scanning Minecraft library..." - case (.localFolder, .reconcile): + case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile): return "Reconciling cached library..." case (.connectedDevice, .fullScan): return "Scanning Minecraft library on device..." @@ -32,7 +32,7 @@ enum SourceScanPolicy { static func performanceContext(for source: MinecraftSource) -> String { switch source.origin { - case .localFolder: + case .localFolder, .javaLocalFolder: return "source=\(source.displayName) kind=local" case .connectedDevice(let device, let container): let transport = device.connection == .usb ? "usb" : "network" @@ -121,7 +121,13 @@ enum SourceScanPolicy { } static func buildSnapshot(for source: MinecraftSource, scanRootURL: URL) -> SourceSnapshot { - let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL) + let collectionSnapshots: [CollectionSnapshot] + switch source.edition { + case .bedrock: + collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL) + case .java: + collectionSnapshots = JavaContentScanner.collectionSnapshots(in: scanRootURL) + } let itemSnapshots = source.rawItems.map { item in ItemSnapshot( @@ -153,6 +159,7 @@ enum SourceScanRecovery { static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) { source.displayItems = previousSource.displayItems source.displayItemCountsByType = previousSource.displayItemCountsByType + source.displayItemCountsByKind = previousSource.displayItemCountsByKind source.rawItems = previousSource.rawItems source.logicalPacks = previousSource.logicalPacks source.logicalWorlds = previousSource.logicalWorlds diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift index f02245d..612c7ae 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -18,26 +18,68 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { } nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { + await accessStatus(for: source).availability + } + + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { guard case .connectedDevice(let expectedDevice, _) = source.origin else { - return .unavailable + return SourceAccessStatus( + availability: .unavailable, + mode: .unknown, + displayName: source.displayName, + iconSystemName: "iphone.gen3", + statusText: "Device source unavailable", + warningText: nil + ) } + let fallbackMode: SourceAccessMode = expectedDevice.connection == .usb ? .usbDevice : .networkDevice + do { let devices = try await listConnectedDevices() guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else { - return .disconnected + return SourceAccessStatus( + availability: .disconnected, + mode: fallbackMode, + displayName: source.displayName, + iconSystemName: "iphone.gen3", + statusText: "Device disconnected", + warningText: nil + ) } + let mode: SourceAccessMode = device.connection == .usb ? .usbDevice : .networkDevice + let availability: SourceAvailability + let statusText: String? switch device.trustState { case .trusted: - return .available + availability = .available + statusText = nil case .locked, .untrusted: - return .limited + availability = .limited + statusText = "Unlock and trust the device" case .unavailable: - return .disconnected + availability = .disconnected + statusText = "Device unavailable" } + + return SourceAccessStatus( + availability: availability, + mode: mode, + displayName: device.name, + iconSystemName: "iphone.gen3", + statusText: statusText, + warningText: nil + ) } catch { - return .disconnected + return SourceAccessStatus( + availability: .disconnected, + mode: fallbackMode, + displayName: source.displayName, + iconSystemName: "iphone.gen3", + statusText: "Device status unavailable", + warningText: error.localizedDescription + ) } } diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index 543ff96..3382368 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable { protocol SourceAccessMethod: Sendable { nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities nonisolated func discoverItems( @@ -18,6 +19,10 @@ protocol SourceAccessMethod: Sendable { mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws + nonisolated func scanEvents( + for source: MinecraftSource, + mode: SourceDiscoveryMode + ) -> AsyncThrowingStream nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] @@ -42,8 +47,13 @@ extension SourceAccessMethod { } nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { - _ = source - return .unknown + await accessStatus(for: source).availability + } + + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { + var status = source.origin.defaultAccessStatus(displayName: source.displayName) + status.availability = .unknown + return status } nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities { @@ -60,6 +70,64 @@ extension SourceAccessMethod { _ = onDiscovered } + nonisolated func scanEvents( + for source: MinecraftSource, + mode: SourceDiscoveryMode + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task.detached(priority: .userInitiated) { + let accessStatus = await accessStatus(for: source) + continuation.yield(.accessStatusChanged(accessStatus)) + continuation.yield( + .stageUpdated( + WorkStage( + id: "discovery", + title: "Discovering content", + detail: nil, + state: .running, + progress: .indeterminate + ) + ) + ) + + do { + try await discoverItems(for: source, mode: mode) { item in + continuation.yield(.discovered(item)) + } + continuation.yield( + .stageUpdated( + WorkStage( + id: "discovery", + title: "Discovering content", + detail: nil, + state: .succeeded, + progress: .indeterminate + ) + ) + ) + continuation.finish() + } catch { + continuation.yield( + .stageUpdated( + WorkStage( + id: "discovery", + title: "Discovering content", + detail: error.localizedDescription, + state: .failed, + progress: .indeterminate + ) + ) + ) + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { _ = source return item @@ -123,9 +191,10 @@ struct SourceAccessCoordinator: SourceAccessMethod { nonisolated init( localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(), + javaLocalFolderAccess: SourceAccessMethod = JavaLocalFolderSourceAccess(), connectedDeviceAccess: ConnectedDeviceSourceAccessMethod ) { - self.init(accessMethods: [localFolderAccess, connectedDeviceAccess]) + self.init(accessMethods: [localFolderAccess, javaLocalFolderAccess, connectedDeviceAccess]) } nonisolated init(accessMethods: [any SourceAccessMethod]) { @@ -164,6 +233,13 @@ struct SourceAccessCoordinator: SourceAccessMethod { ) } + nonisolated func scanEvents( + for source: MinecraftSource, + mode: SourceDiscoveryMode + ) -> AsyncThrowingStream { + accessMethod(for: source).scanEvents(for: source, mode: mode) + } + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { accessMethod(for: source).accessDescriptor(for: source) } @@ -172,6 +248,10 @@ struct SourceAccessCoordinator: SourceAccessMethod { return await accessMethod(for: source).availability(for: source) } + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { + return await accessMethod(for: source).accessStatus(for: source) + } + nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities { return await accessMethod(for: source).capabilities(for: source) } diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index d9a04fa..89cc000 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -3,7 +3,9 @@ import Foundation -struct LocalFolderSourceAccess: SourceAccessMethod { +typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess + +struct BedrockLocalFolderSourceAccess: SourceAccessMethod { nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder" nonisolated init() {} @@ -18,9 +20,15 @@ struct LocalFolderSourceAccess: SourceAccessMethod { } nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability { + await accessStatus(for: source).availability + } + + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { let candidateURL: URL + let mode: SourceAccessMode if case .localFolder(let bookmarkData) = source.origin, let bookmarkData { + mode = .securityScopedLocalFolder var isStale = false if let resolvedURL = try? URL( resolvingBookmarkData: bookmarkData, @@ -33,10 +41,19 @@ struct LocalFolderSourceAccess: SourceAccessMethod { candidateURL = source.folderURL } } else { + mode = .localFileSystem candidateURL = source.folderURL } - return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable + let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable + return SourceAccessStatus( + availability: availability, + mode: mode, + displayName: source.displayName, + iconSystemName: "folder", + statusText: availability == .available ? nil : "Folder unavailable", + warningText: nil + ) } nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities { @@ -191,3 +208,117 @@ struct LocalFolderSourceAccess: SourceAccessMethod { 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 + } +} diff --git a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift index 2cdd868..ad1d02c 100644 --- a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift @@ -183,7 +183,7 @@ struct SourceDetailView: View { } switch source.origin { - case .localFolder: + case .localFolder, .javaLocalFolder: break case .connectedDevice(let device, let container): rows.append(("Connection", device.connection == .network ? "Network" : "USB")) @@ -209,7 +209,7 @@ struct SourceDetailView: View { private var locationRows: [(String, String)] { switch source.origin { - case .localFolder: + case .localFolder, .javaLocalFolder: return [("Filesystem Path", source.folderURL.path)] case .connectedDevice(_, let container): var rows: [(String, String)] = [ @@ -224,7 +224,7 @@ struct SourceDetailView: View { private var technicalRows: [(String, String)] { switch source.origin { - case .localFolder: + case .localFolder, .javaLocalFolder: return [] case .connectedDevice(let device, let container): var rows: [(String, String)] = [ @@ -244,6 +244,8 @@ struct SourceDetailView: View { switch source.origin { case .localFolder: return "Local Folder" + case .javaLocalFolder: + return "Java Local Folder" case .connectedDevice: return "Connected Device" } diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index ad4b8ba..0ff7781 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -144,6 +144,9 @@ enum PreviewFixtures { source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in counts[item.contentType, default: 0] += 1 } + source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in + counts[item.contentKind, default: 0] += 1 + } source.rawItems = source.displayItems source.logicalPacks = [ LogicalPack( @@ -229,6 +232,9 @@ enum PreviewFixtures { source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in counts[item.contentType, default: 0] += 1 } + source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in + counts[item.contentKind, default: 0] += 1 + } source.rawItems = source.displayItems source.indexedItemCount = source.displayItems.count source.indexedDetailCount = source.displayItems.count diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 04c540a..0ff438e 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -331,16 +331,27 @@ struct ContentView: View { } private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { - return MinecraftContentType.allCases.compactMap { contentType in - guard let count = source.displayItemCountsByType[contentType], count > 0 else { + let orderedKinds: [MinecraftContentKind] = [ + .world, + .behaviorPack, + .resourcePack, + .dataPack, + .skinPack, + .worldTemplate, + .shaderPack, + .mod + ] + + return orderedKinds.compactMap { contentKind in + guard let count = source.displayItemCountsByKind[contentKind], count > 0 else { return nil } return SidebarFilter( - title: sidebarTitle(for: contentType), - iconName: sidebarIcon(for: contentType), + title: sidebarTitle(for: contentKind), + iconName: sidebarIcon(for: contentKind), count: count, - selection: .contentType(sourceID: source.id, contentType: contentType) + selection: .contentKind(sourceID: source.id, contentKind: contentKind) ) } } @@ -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 { switch contentType { 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 private func itemContextMenu(for item: MinecraftContentItem) -> some View { Button("Share...") { @@ -767,7 +820,7 @@ struct ContentView: View { } private func archiveType(for item: MinecraftContentItem) -> UTType { - UTType(filenameExtension: item.contentType.archiveExtension) ?? .data + itemActionService.archiveContentType(for: item) } private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider { diff --git a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift index 2c58427..1272097 100644 --- a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift +++ b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift @@ -81,6 +81,8 @@ enum ItemCollectionProjector { return "All Items" case .contentType(_, let contentType): return sidebarTitle(for: contentType) + case .contentKind(_, let contentKind): + return sidebarTitle(for: contentKind) } } @@ -92,6 +94,8 @@ enum ItemCollectionProjector { return "Search All Items" case .some(.contentType(_, let contentType)): return "Search \(sidebarTitle(for: contentType))" + case .some(.contentKind(_, let contentKind)): + return "Search \(sidebarTitle(for: contentKind))" case .none: return "Search Library" } @@ -105,6 +109,8 @@ enum ItemCollectionProjector { return "All" case .some(.contentType(_, let contentType)): return sidebarTitle(for: contentType) + case .some(.contentKind(_, let contentKind)): + return sidebarTitle(for: contentKind) case .none: return "Library" } @@ -125,6 +131,17 @@ enum ItemCollectionProjector { case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: return scopedItemCount == 1 ? "pack" : "packs" } + case .contentKind(_, let contentKind): + switch contentKind { + case .world: + return scopedItemCount == 1 ? "world" : "worlds" + case .mod: + return scopedItemCount == 1 ? "mod" : "mods" + case .shaderPack: + return scopedItemCount == 1 ? "shader pack" : "shader packs" + case .behaviorPack, .resourcePack, .dataPack, .skinPack, .worldTemplate: + return scopedItemCount == 1 ? "pack" : "packs" + } } } @@ -143,6 +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 { request.searchText.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index b312501..84009cd 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -7,10 +7,11 @@ enum SidebarSelection: Hashable, Sendable { case source(sourceID: URL) case allContent(sourceID: URL) case contentType(sourceID: URL, contentType: MinecraftContentType) + case contentKind(sourceID: URL, contentKind: MinecraftContentKind) var sourceID: URL { switch self { - case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _): + case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _): return sourceID } } @@ -197,7 +198,7 @@ private struct SourceHeaderRow: View { private var headerSymbolName: String { switch source.origin { - case .localFolder: + case .localFolder, .javaLocalFolder: return "folder" case .connectedDevice: return "iphone.gen3" diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index a1b1cb7..19a9b2e 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -13,6 +13,9 @@ struct World_Manager_for_MinecraftTests { @Test func sourceOriginsExposeOutboundCapabilities() async throws { let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local")) #expect(localSource.capabilities == .localFolder) + #expect(localSource.edition == .bedrock) + #expect(localSource.providerID == LocalFolderSourceAccess().accessorIdentifier) + #expect(localSource.accessStatus.mode == .localFileSystem) let device = ConnectedDevice( udid: "device", @@ -35,6 +38,172 @@ struct World_Manager_for_MinecraftTests { ) #expect(deviceSource.capabilities == .connectedDevice) + #expect(deviceSource.edition == .bedrock) + #expect(deviceSource.providerID == AppleMobileDeviceSourceAccess().accessorIdentifier) + #expect(deviceSource.accessStatus.mode == .usbDevice) + } + + @Test func contentItemsExposeNeutralProviderSurface() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/source") + let item = MinecraftContentItem( + folderURL: rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true), + folderName: "WorldA", + contentType: .world, + collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true), + displayName: "World A", + packUUID: "ABC-123", + packVersion: "1.0.0" + ) + + #expect(item.sourceEdition == .bedrock) + #expect(item.contentKind == .world) + #expect(item.platformType == .bedrock(.world)) + #expect(item.capabilities.portablePackageExtension == "mcworld") + if case .bedrock(let metadata) = item.platformMetadata { + #expect(metadata.packUUID == "abc-123") + #expect(metadata.packVersion == "1.0.0") + } else { + Issue.record("Expected Bedrock metadata") + } + } + + @Test func bedrockCompatibilityFieldsSynchronizePlatformMetadata() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/source") + var item = MinecraftContentItem( + folderURL: rootURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true), + folderName: "PackA", + contentType: .behaviorPack, + collectionRootURL: rootURL.appendingPathComponent("behavior_packs", isDirectory: true) + ) + + item.packUUID = "PACK-A" + item.packVersion = "2.0.0" + + if case .bedrock(let metadata) = item.platformMetadata { + #expect(metadata.packUUID == "pack-a") + #expect(metadata.packVersion == "2.0.0") + } else { + Issue.record("Expected Bedrock metadata") + } + } + + @Test func localFolderAccessStreamsProviderEvents() async throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true) + defer { try? fileManager.removeItem(at: rootURL) } + + try fileManager.createDirectory(at: itemURL, withIntermediateDirectories: true) + try "World A".write( + to: itemURL.appendingPathComponent("levelname.txt"), + atomically: true, + encoding: .utf8 + ) + + let source = MinecraftSource(folderURL: rootURL) + let access = LocalFolderSourceAccess() + var sawAccessStatus = false + var sawRunningStage = false + var sawFinishedStage = false + var discoveredItems: [MinecraftContentItem] = [] + + for try await event in access.scanEvents(for: source, mode: .fullScan) { + switch event { + case .accessStatusChanged(let status): + sawAccessStatus = true + #expect(status.availability == .available) + #expect(status.mode == .localFileSystem) + case .stageUpdated(let stage): + if stage.state == .running { + sawRunningStage = true + } + if stage.state == .succeeded { + sawFinishedStage = true + } + case .discovered(let item): + discoveredItems.append(item) + case .inspected, .warning: + break + } + } + + #expect(sawAccessStatus) + #expect(sawRunningStage) + #expect(sawFinishedStage) + #expect(discoveredItems.map(\.displayName).contains("WorldA")) + } + + @Test func javaLocalFolderSourceUsesJavaProviderDefaults() async throws { + let source = MinecraftSource( + folderURL: URL(fileURLWithPath: "/tmp/java"), + origin: .javaLocalFolder(bookmarkData: nil) + ) + + #expect(source.edition == .java) + #expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier) + #expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier) + #expect(source.accessStatus.mode == .localFileSystem) + } + + @Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let 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 { diff --git a/docs/provider-architecture-design.md b/docs/provider-architecture-design.md new file mode 100644 index 0000000..ee1e086 --- /dev/null +++ b/docs/provider-architecture-design.md @@ -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 + 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. diff --git a/docs/provider-refactor-migration-plan.md b/docs/provider-refactor-migration-plan.md new file mode 100644 index 0000000..3b18ceb --- /dev/null +++ b/docs/provider-refactor-migration-plan.md @@ -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.