First refactor
This commit is contained in:
parent
3a97ae0d53
commit
14d9048b57
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
196
docs/provider-architecture-design.md
Normal file
196
docs/provider-architecture-design.md
Normal 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.
|
||||||
92
docs/provider-refactor-migration-plan.md
Normal file
92
docs/provider-refactor-migration-plan.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user