First refactor

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,61 @@ nonisolated struct SourceAccessDescriptor: Hashable, Sendable, Codable {
var refreshStrategy: SourceRefreshStrategy
}
nonisolated enum SourceAccessMode: String, Hashable, Sendable, Codable {
case localFileSystem
case securityScopedLocalFolder
case usbDevice
case networkDevice
case archive
case unknown
}
nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable {
var availability: SourceAvailability
var mode: SourceAccessMode
var displayName: String
var iconSystemName: String
var statusText: String?
var warningText: String?
}
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending
case running
case succeeded
case failed
case skipped
case cancelled
}
nonisolated enum WorkProgress: Hashable, Sendable, Codable {
case indeterminate
case fraction(Double)
case count(completed: Int, total: Int?)
}
nonisolated struct WorkStage: Identifiable, Hashable, Sendable, Codable {
let id: String
var title: String
var detail: String?
var state: WorkStageState
var progress: WorkProgress
}
nonisolated struct ProviderWarning: Identifiable, Hashable, Sendable, Codable {
let id: String
var message: String
var detail: String?
}
nonisolated enum ProviderEvent: Sendable {
case accessStatusChanged(SourceAccessStatus)
case stageUpdated(WorkStage)
case discovered(MinecraftContentItem)
case inspected(MinecraftContentItem)
case warning(ProviderWarning)
}
nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
let id: URL
var displayName: String

View File

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

View File

@ -46,7 +46,7 @@ enum ContentPackageExporter {
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
"\(suggestedBaseFilename(for: item)).\(archiveExtension(for: item))"
}
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
@ -209,7 +209,7 @@ enum ContentPackageExporter {
return requestDirectoryURL
.appendingPathComponent(suggestedBaseFilename(for: item))
.appendingPathExtension(item.contentType.archiveExtension)
.appendingPathExtension(archiveExtension(for: item))
}
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String {
@ -271,7 +271,7 @@ enum ContentPackageExporter {
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension
let requiredExtension = archiveExtension(for: item)
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL
@ -280,6 +280,10 @@ enum ContentPackageExporter {
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
}
nonisolated private static func archiveExtension(for item: MinecraftContentItem) -> String {
item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension
}
nonisolated private static func uniqueArchiveURL(
in directoryURL: URL,
baseName: String,

View File

@ -3,7 +3,9 @@
import Foundation
enum WorldScanner {
typealias WorldScanner = BedrockContentScanner
enum BedrockContentScanner {
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
let fileManager = FileManager.default
var sizedItem = item
@ -162,7 +164,7 @@ enum WorldScanner {
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
: nil
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
enrichedItem.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version
@ -322,11 +324,11 @@ enum WorldScanner {
return worldMetadata?.lastPlayedDate
}
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
nonisolated fileprivate static func modifiedDate(for directoryURL: URL) -> Date? {
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
}
nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
nonisolated fileprivate static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
@ -580,4 +582,177 @@ private actor PackReferenceIndexStore {
}
}
enum JavaContentScanner {
nonisolated static func discoverItems(
in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
) throws -> [MinecraftContentItem] {
let fileManager = FileManager.default
var discoveredItems: [MinecraftContentItem] = []
let savesRootURL = existingDirectory(
named: "saves",
in: searchRootURL,
fileManager: fileManager
) ?? searchRootURL
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
discoveredItems.append(contentsOf: worldItems)
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: searchRootURL, fileManager: fileManager) {
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
discoveredItems.append(contentsOf: resourcePackItems)
}
discoveredItems.sort(by: WorldScanner.sortItems)
discoveredItems.forEach(onDiscovered)
return discoveredItems
}
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
var enrichedItem = item
enrichedItem.displayName = displayName(for: item)
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
enrichedItem.sizeLoaded = false
return enrichedItem
}
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
var sizedItem = item
sizedItem.sizeBytes = WorldScanner.folderSize(at: item.folderURL, fileManager: .default)
sizedItem.sizeLoaded = true
return sizedItem
}
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
let fileManager = FileManager.default
let candidateRoots = [
existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager),
existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager)
]
return candidateRoots.compactMap { collectionURL in
guard let collectionURL else {
return nil
}
return collectionSnapshot(for: collectionURL, fileManager: fileManager)
}
}
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
let worldDirectories = try WorldScanner.immediateChildDirectories(of: savesRootURL, fileManager: fileManager)
return worldDirectories.compactMap { worldURL in
guard fileManager.fileExists(atPath: worldURL.appendingPathComponent("level.dat").path) else {
return nil
}
return MinecraftContentItem(
folderURL: worldURL,
folderName: worldURL.lastPathComponent,
contentType: .world,
sourceEdition: .java,
contentKind: .world,
platformType: .java(.world),
collectionRootURL: savesRootURL,
capabilities: .java(contentType: .world),
platformMetadata: .java(JavaContentMetadata())
)
}
}
nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
let directories = try WorldScanner.immediateChildDirectories(of: resourcePacksURL, fileManager: fileManager)
return directories.compactMap { packURL in
guard fileManager.fileExists(atPath: packURL.appendingPathComponent("pack.mcmeta").path) else {
return nil
}
return MinecraftContentItem(
folderURL: packURL,
folderName: packURL.lastPathComponent,
contentType: .resourcePack,
sourceEdition: .java,
contentKind: .resourcePack,
platformType: .java(.resourcePack),
collectionRootURL: resourcePacksURL,
capabilities: .java(contentType: .resourcePack),
platformMetadata: .java(JavaContentMetadata())
)
}
}
nonisolated private static func existingDirectory(named name: String, in rootURL: URL, fileManager: FileManager) -> URL? {
let directoryURL = rootURL.appendingPathComponent(name, isDirectory: true)
guard (try? directoryURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
return nil
}
return directoryURL
}
nonisolated private static func collectionSnapshot(
for collectionURL: URL,
fileManager: FileManager
) -> CollectionSnapshot? {
guard fileManager.fileExists(atPath: collectionURL.path) else {
return nil
}
let children = (try? fileManager.contentsOfDirectory(
at: collectionURL,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
)) ?? []
let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in
guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
return nil
}
let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
return (childURL.lastPathComponent, modifiedDate)
}.sorted {
$0.name.localizedStandardCompare($1.name) == .orderedAscending
}
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
let childFingerprint = childDirectorySnapshots.map { child in
[
child.name,
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
].joined(separator: "@")
}.joined(separator: "|")
return CollectionSnapshot(
folderName: collectionURL.lastPathComponent,
modifiedDate: modifiedDate,
childDirectoryCount: childDirectorySnapshots.count,
fingerprint: [
collectionURL.lastPathComponent,
String(childDirectorySnapshots.count),
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
childFingerprint
].joined(separator: "::")
)
}
nonisolated private static func displayName(for item: MinecraftContentItem) -> String {
guard item.contentKind == .world else {
return item.folderName
}
let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
guard
let value = try? String(contentsOf: levelNameURL, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
return item.folderName
}
return value
}
}
private let packReferenceIndexStore = PackReferenceIndexStore()

View File

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

View File

@ -147,6 +147,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.bookmarkData = bookmarkData
}
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
source.providerID = source.accessDescriptor.accessorIdentifier
source.capabilities = source.origin.defaultCapabilities
}
startScan(for: normalizedURL, mode: .fullScan)
@ -171,6 +172,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
updateSource(source.id) { existingSource in
existingSource.origin = source.origin
existingSource.accessDescriptor = source.accessDescriptor
existingSource.providerID = source.accessDescriptor.accessorIdentifier
existingSource.edition = source.origin.defaultEdition
existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName)
existingSource.availability = source.availability
existingSource.capabilities = source.capabilities
if existingSource.bookmarkData == nil {
@ -183,6 +187,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} else {
var resolvedSource = source
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier
resolvedSource.edition = resolvedSource.origin.defaultEdition
resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName)
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
sources.append(resolvedSource)
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
@ -320,6 +327,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.worldPackRelationships = index.worldPackRelationships
source.displayItems = index.displayItems
source.displayItemCountsByType = index.displayItemCountsByType
source.displayItemCountsByKind = index.displayItemCountsByKind
}
}
@ -362,6 +370,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
updateSource(sourceID) { source in
source.displayItems = snapshot.displayItems
source.displayItemCountsByType = snapshot.displayItemCountsByType
source.displayItemCountsByKind = snapshot.displayItemCountsByKind
source.rawItems = snapshot.rawItems
source.logicalPacks = snapshot.logicalPacks
source.logicalWorlds = snapshot.logicalWorlds

View File

@ -56,6 +56,9 @@ enum SourceRestoration {
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.indexedItemCount = items.count
source.indexedDetailCount = items.filter(\.metadataLoaded).count
source.previewLoadedCount = items.filter(\.previewLoaded).count

View File

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

View File

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

View File

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

View File

@ -18,26 +18,68 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
return .unavailable
return SourceAccessStatus(
availability: .unavailable,
mode: .unknown,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device source unavailable",
warningText: nil
)
}
let fallbackMode: SourceAccessMode = expectedDevice.connection == .usb ? .usbDevice : .networkDevice
do {
let devices = try await listConnectedDevices()
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
return .disconnected
return SourceAccessStatus(
availability: .disconnected,
mode: fallbackMode,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device disconnected",
warningText: nil
)
}
let mode: SourceAccessMode = device.connection == .usb ? .usbDevice : .networkDevice
let availability: SourceAvailability
let statusText: String?
switch device.trustState {
case .trusted:
return .available
availability = .available
statusText = nil
case .locked, .untrusted:
return .limited
availability = .limited
statusText = "Unlock and trust the device"
case .unavailable:
return .disconnected
availability = .disconnected
statusText = "Device unavailable"
}
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: device.name,
iconSystemName: "iphone.gen3",
statusText: statusText,
warningText: nil
)
} catch {
return .disconnected
return SourceAccessStatus(
availability: .disconnected,
mode: fallbackMode,
displayName: source.displayName,
iconSystemName: "iphone.gen3",
statusText: "Device status unavailable",
warningText: error.localizedDescription
)
}
}

View File

@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable {
protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities
nonisolated func discoverItems(
@ -18,6 +19,10 @@ protocol SourceAccessMethod: Sendable {
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error>
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
@ -42,8 +47,13 @@ extension SourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
_ = source
return .unknown
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
var status = source.origin.defaultAccessStatus(displayName: source.displayName)
status.availability = .unknown
return status
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -60,6 +70,64 @@ extension SourceAccessMethod {
_ = onDiscovered
}
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task.detached(priority: .userInitiated) {
let accessStatus = await accessStatus(for: source)
continuation.yield(.accessStatusChanged(accessStatus))
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: nil,
state: .running,
progress: .indeterminate
)
)
)
do {
try await discoverItems(for: source, mode: mode) { item in
continuation.yield(.discovered(item))
}
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: nil,
state: .succeeded,
progress: .indeterminate
)
)
)
continuation.finish()
} catch {
continuation.yield(
.stageUpdated(
WorkStage(
id: "discovery",
title: "Discovering content",
detail: error.localizedDescription,
state: .failed,
progress: .indeterminate
)
)
)
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return item
@ -123,9 +191,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
nonisolated init(
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
javaLocalFolderAccess: SourceAccessMethod = JavaLocalFolderSourceAccess(),
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
) {
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess])
self.init(accessMethods: [localFolderAccess, javaLocalFolderAccess, connectedDeviceAccess])
}
nonisolated init(accessMethods: [any SourceAccessMethod]) {
@ -164,6 +233,13 @@ struct SourceAccessCoordinator: SourceAccessMethod {
)
}
nonisolated func scanEvents(
for source: MinecraftSource,
mode: SourceDiscoveryMode
) -> AsyncThrowingStream<ProviderEvent, Error> {
accessMethod(for: source).scanEvents(for: source, mode: mode)
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
accessMethod(for: source).accessDescriptor(for: source)
}
@ -172,6 +248,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).availability(for: source)
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
return await accessMethod(for: source).accessStatus(for: source)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
return await accessMethod(for: source).capabilities(for: source)
}

View File

@ -3,7 +3,9 @@
import Foundation
struct LocalFolderSourceAccess: SourceAccessMethod {
typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess
struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
nonisolated init() {}
@ -18,9 +20,15 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
await accessStatus(for: source).availability
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
let candidateURL: URL
let mode: SourceAccessMode
if case .localFolder(let bookmarkData) = source.origin,
let bookmarkData {
mode = .securityScopedLocalFolder
var isStale = false
if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData,
@ -33,10 +41,19 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
candidateURL = source.folderURL
}
} else {
mode = .localFileSystem
candidateURL = source.folderURL
}
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: source.displayName,
iconSystemName: "folder",
statusText: availability == .available ? nil : "Folder unavailable",
warningText: nil
)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
@ -191,3 +208,117 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
return components.first.map(String.init)
}
}
struct JavaLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder"
nonisolated init() {}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source
return SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
}
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
let candidateURL: URL
let mode: SourceAccessMode
if case .javaLocalFolder(let bookmarkData) = source.origin,
let bookmarkData {
mode = .securityScopedLocalFolder
var isStale = false
if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) {
candidateURL = resolvedURL.standardizedFileURL
} else {
candidateURL = source.folderURL
}
} else {
mode = .localFileSystem
candidateURL = source.folderURL
}
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
return SourceAccessStatus(
availability: availability,
mode: mode,
displayName: source.displayName,
iconSystemName: "folder",
statusText: availability == .available ? nil : "Folder unavailable",
warningText: nil
)
}
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
_ = source
return .localFolder
}
nonisolated func discoverItems(
for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws {
_ = mode
guard case .javaLocalFolder(let bookmarkData) = source.origin else {
throw SourceAccessError.accessFailed(
reason: "No Java local-folder access method is configured for this source type."
)
}
let resolvedURL: URL
if let bookmarkData {
var isStale = false
guard let bookmarkURL = try? URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else {
throw SourceAccessError.accessFailed(
reason: "The saved folder bookmark could not be resolved."
)
}
resolvedURL = bookmarkURL.standardizedFileURL
} else {
resolvedURL = source.folderURL
}
let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource()
defer {
if accessedSecurityScope {
resolvedURL.stopAccessingSecurityScopedResource()
}
}
_ = try JavaContentScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return JavaContentScanner.enrich(item: item)
}
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return JavaContentScanner.loadSize(for: item)
}
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
_ = source
return try await BedrockLocalFolderSourceAccess().listItemContents(for: item, in: source)
}
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
_ = source
return item.folderURL
}
}

View File

@ -183,7 +183,7 @@ struct SourceDetailView: View {
}
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
break
case .connectedDevice(let device, let container):
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
@ -209,7 +209,7 @@ struct SourceDetailView: View {
private var locationRows: [(String, String)] {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return [("Filesystem Path", source.folderURL.path)]
case .connectedDevice(_, let container):
var rows: [(String, String)] = [
@ -224,7 +224,7 @@ struct SourceDetailView: View {
private var technicalRows: [(String, String)] {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return []
case .connectedDevice(let device, let container):
var rows: [(String, String)] = [
@ -244,6 +244,8 @@ struct SourceDetailView: View {
switch source.origin {
case .localFolder:
return "Local Folder"
case .javaLocalFolder:
return "Java Local Folder"
case .connectedDevice:
return "Connected Device"
}

View File

@ -144,6 +144,9 @@ enum PreviewFixtures {
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.rawItems = source.displayItems
source.logicalPacks = [
LogicalPack(
@ -229,6 +232,9 @@ enum PreviewFixtures {
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
counts[item.contentKind, default: 0] += 1
}
source.rawItems = source.displayItems
source.indexedItemCount = source.displayItems.count
source.indexedDetailCount = source.displayItems.count

View File

@ -331,16 +331,27 @@ struct ContentView: View {
}
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
return MinecraftContentType.allCases.compactMap { contentType in
guard let count = source.displayItemCountsByType[contentType], count > 0 else {
let orderedKinds: [MinecraftContentKind] = [
.world,
.behaviorPack,
.resourcePack,
.dataPack,
.skinPack,
.worldTemplate,
.shaderPack,
.mod
]
return orderedKinds.compactMap { contentKind in
guard let count = source.displayItemCountsByKind[contentKind], count > 0 else {
return nil
}
return SidebarFilter(
title: sidebarTitle(for: contentType),
iconName: sidebarIcon(for: contentType),
title: sidebarTitle(for: contentKind),
iconName: sidebarIcon(for: contentKind),
count: count,
selection: .contentType(sourceID: source.id, contentType: contentType)
selection: .contentKind(sourceID: source.id, contentKind: contentKind)
)
}
}
@ -372,6 +383,27 @@ struct ContentView: View {
}
}
private func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .dataPack:
return "Data Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
case .shaderPack:
return "Shader Packs"
case .mod:
return "Mods"
}
}
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
@ -387,6 +419,27 @@ struct ContentView: View {
}
}
private func sidebarIcon(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .dataPack:
return "curlybraces.square"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "map"
case .shaderPack:
return "camera.filters"
case .mod:
return "hammer"
}
}
@ViewBuilder
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
Button("Share...") {
@ -767,7 +820,7 @@ struct ContentView: View {
}
private func archiveType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
itemActionService.archiveContentType(for: item)
}
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {

View File

@ -81,6 +81,8 @@ enum ItemCollectionProjector {
return "All Items"
case .contentType(_, let contentType):
return sidebarTitle(for: contentType)
case .contentKind(_, let contentKind):
return sidebarTitle(for: contentKind)
}
}
@ -92,6 +94,8 @@ enum ItemCollectionProjector {
return "Search All Items"
case .some(.contentType(_, let contentType)):
return "Search \(sidebarTitle(for: contentType))"
case .some(.contentKind(_, let contentKind)):
return "Search \(sidebarTitle(for: contentKind))"
case .none:
return "Search Library"
}
@ -105,6 +109,8 @@ enum ItemCollectionProjector {
return "All"
case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType)
case .some(.contentKind(_, let contentKind)):
return sidebarTitle(for: contentKind)
case .none:
return "Library"
}
@ -125,6 +131,17 @@ enum ItemCollectionProjector {
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs"
}
case .contentKind(_, let contentKind):
switch contentKind {
case .world:
return scopedItemCount == 1 ? "world" : "worlds"
case .mod:
return scopedItemCount == 1 ? "mod" : "mods"
case .shaderPack:
return scopedItemCount == 1 ? "shader pack" : "shader packs"
case .behaviorPack, .resourcePack, .dataPack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs"
}
}
}
@ -143,6 +160,27 @@ enum ItemCollectionProjector {
}
}
nonisolated private static func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .dataPack:
return "Data Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
case .shaderPack:
return "Shader Packs"
case .mod:
return "Mods"
}
}
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
}

View File

@ -7,10 +7,11 @@ enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL {
switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _):
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID
}
}
@ -197,7 +198,7 @@ private struct SourceHeaderRow: View {
private var headerSymbolName: String {
switch source.origin {
case .localFolder:
case .localFolder, .javaLocalFolder:
return "folder"
case .connectedDevice:
return "iphone.gen3"

View File

@ -13,6 +13,9 @@ struct World_Manager_for_MinecraftTests {
@Test func sourceOriginsExposeOutboundCapabilities() async throws {
let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local"))
#expect(localSource.capabilities == .localFolder)
#expect(localSource.edition == .bedrock)
#expect(localSource.providerID == LocalFolderSourceAccess().accessorIdentifier)
#expect(localSource.accessStatus.mode == .localFileSystem)
let device = ConnectedDevice(
udid: "device",
@ -35,6 +38,172 @@ struct World_Manager_for_MinecraftTests {
)
#expect(deviceSource.capabilities == .connectedDevice)
#expect(deviceSource.edition == .bedrock)
#expect(deviceSource.providerID == AppleMobileDeviceSourceAccess().accessorIdentifier)
#expect(deviceSource.accessStatus.mode == .usbDevice)
}
@Test func contentItemsExposeNeutralProviderSurface() async throws {
let rootURL = URL(fileURLWithPath: "/tmp/source")
let item = MinecraftContentItem(
folderURL: rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true),
folderName: "WorldA",
contentType: .world,
collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true),
displayName: "World A",
packUUID: "ABC-123",
packVersion: "1.0.0"
)
#expect(item.sourceEdition == .bedrock)
#expect(item.contentKind == .world)
#expect(item.platformType == .bedrock(.world))
#expect(item.capabilities.portablePackageExtension == "mcworld")
if case .bedrock(let metadata) = item.platformMetadata {
#expect(metadata.packUUID == "abc-123")
#expect(metadata.packVersion == "1.0.0")
} else {
Issue.record("Expected Bedrock metadata")
}
}
@Test func bedrockCompatibilityFieldsSynchronizePlatformMetadata() async throws {
let rootURL = URL(fileURLWithPath: "/tmp/source")
var item = MinecraftContentItem(
folderURL: rootURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true),
folderName: "PackA",
contentType: .behaviorPack,
collectionRootURL: rootURL.appendingPathComponent("behavior_packs", isDirectory: true)
)
item.packUUID = "PACK-A"
item.packVersion = "2.0.0"
if case .bedrock(let metadata) = item.platformMetadata {
#expect(metadata.packUUID == "pack-a")
#expect(metadata.packVersion == "2.0.0")
} else {
Issue.record("Expected Bedrock metadata")
}
}
@Test func localFolderAccessStreamsProviderEvents() async throws {
let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true)
defer { try? fileManager.removeItem(at: rootURL) }
try fileManager.createDirectory(at: itemURL, withIntermediateDirectories: true)
try "World A".write(
to: itemURL.appendingPathComponent("levelname.txt"),
atomically: true,
encoding: .utf8
)
let source = MinecraftSource(folderURL: rootURL)
let access = LocalFolderSourceAccess()
var sawAccessStatus = false
var sawRunningStage = false
var sawFinishedStage = false
var discoveredItems: [MinecraftContentItem] = []
for try await event in access.scanEvents(for: source, mode: .fullScan) {
switch event {
case .accessStatusChanged(let status):
sawAccessStatus = true
#expect(status.availability == .available)
#expect(status.mode == .localFileSystem)
case .stageUpdated(let stage):
if stage.state == .running {
sawRunningStage = true
}
if stage.state == .succeeded {
sawFinishedStage = true
}
case .discovered(let item):
discoveredItems.append(item)
case .inspected, .warning:
break
}
}
#expect(sawAccessStatus)
#expect(sawRunningStage)
#expect(sawFinishedStage)
#expect(discoveredItems.map(\.displayName).contains("WorldA"))
}
@Test func javaLocalFolderSourceUsesJavaProviderDefaults() async throws {
let source = MinecraftSource(
folderURL: URL(fileURLWithPath: "/tmp/java"),
origin: .javaLocalFolder(bookmarkData: nil)
)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessStatus.mode == .localFileSystem)
}
@Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws {
let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let worldURL = rootURL.appendingPathComponent("saves/JavaWorld", isDirectory: true)
let packURL = rootURL.appendingPathComponent("resourcepacks/JavaPack", isDirectory: true)
defer { try? fileManager.removeItem(at: rootURL) }
try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true)
try Data().write(to: worldURL.appendingPathComponent("level.dat"))
try "Displayed Java World".write(
to: worldURL.appendingPathComponent("levelname.txt"),
atomically: true,
encoding: .utf8
)
try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true)
try "{}".write(
to: packURL.appendingPathComponent("pack.mcmeta"),
atomically: true,
encoding: .utf8
)
let source = MinecraftSource(
folderURL: rootURL,
origin: .javaLocalFolder(bookmarkData: nil)
)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
var discoveredItems: [MinecraftContentItem] = []
for try await event in access.scanEvents(for: source, mode: .fullScan) {
if case .discovered(let item) = event {
discoveredItems.append(item)
}
}
var enrichedItems: [MinecraftContentItem] = []
for item in discoveredItems {
enrichedItems.append(await access.enrich(item, for: source))
}
#expect(discoveredItems.count == 2)
#expect(discoveredItems.allSatisfy { $0.sourceEdition == .java })
#expect(discoveredItems.contains { $0.platformType == .java(.world) && $0.capabilities.portablePackageExtension == "zip" })
#expect(discoveredItems.contains { $0.platformType == .java(.resourcePack) && $0.contentType == .resourcePack })
#expect(enrichedItems.contains { $0.displayName == "Displayed Java World" })
var indexedSource = source
indexedSource.rawItems = enrichedItems
let index = SourceContentIndexer.buildIndex(for: indexedSource)
#expect(index.displayItemCountsByKind[.world] == 1)
#expect(index.displayItemCountsByKind[.resourcePack] == 1)
indexedSource.rawItems = enrichedItems
let snapshot = SourceScanPolicy.buildSnapshot(for: indexedSource, scanRootURL: rootURL)
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("saves"))
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("resourcepacks"))
}
@Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws {

View File

@ -0,0 +1,196 @@
# Provider Architecture Design
World Manager should treat Minecraft libraries as sources of content units that
can be discovered, inspected, cached, displayed, materialized, exported, and
eventually copied between sources. Bedrock local folders, Bedrock iOS devices,
Java local folders, and possible future platforms should be cohesive provider
modules behind one engine contract.
## Goals
- Keep provider-specific knowledge inside provider modules.
- Give the UI a uniform model for equivalent concepts: name, edition, kind,
source, icon, dates, size, availability, progress, and actions.
- Preserve variable metadata for platform details that do not translate across
editions.
- Allow scan workflows to stream events instead of forcing every provider into
the same fixed stages.
- Keep connected-device and remote-like sources free to throttle work more
aggressively than local filesystem sources.
## Shape
```text
Platforms/Bedrock
BedrockLocalFolderProvider
BedrockIOSDeviceProvider
BedrockContentScanner
BedrockMetadataReader
BedrockExporter
BedrockMaterializer
Platforms/Java
JavaLocalFolderProvider
JavaContentScanner
JavaMetadataReader
JavaExporter
JavaMaterializer
Engine
ProviderRegistry
SourceEngine
Scan orchestration
Cache/snapshot persistence
Generic indexing/search/projection
Generic action dispatch
UI
Generic source and item surfaces
Provider/edition-specific detail sections
```
## Core Concepts
### Provider
A provider is the unit that knows a platform and access method. A provider can
represent an edition/access pairing such as Bedrock local folder, Bedrock iOS
device, or Java local folder.
```swift
protocol MinecraftPlatformProvider: Sendable {
var id: PlatformProviderID { get }
var displayName: String { get }
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
func capabilities(for source: MinecraftSource) async -> SourceCapabilities
func scan(_ request: ProviderScanRequest) -> AsyncThrowingStream<ProviderEvent, Error>
func materialize(_ unit: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
func export(_ unit: MinecraftContentItem, in source: MinecraftSource, request: ExportRequest) async throws -> URL
}
```
The current `SourceAccessMethod` is already close to this role. The refactor
should evolve it rather than replace the whole source system at once.
### Content Unit
The engine should pass around an edition-aware content unit with a strong common
surface and boxed platform metadata.
```swift
struct MinecraftContentItem {
let id: URL
let folderURL: URL
let folderName: String
let sourceEdition: MinecraftEdition
let contentKind: MinecraftContentKind
let platformType: MinecraftPlatformContentType
let collectionRootURL: URL
var displayName: String
var iconURL: URL?
var modifiedDate: Date?
var sizeBytes: Int64?
var capabilities: ContentItemCapabilities
var platformMetadata: PlatformContentMetadata
}
```
The model can retain compatibility shims during migration, but new code should
prefer edition, kind, capabilities, and boxed metadata over Bedrock-only fields.
### Metadata
Generic UI should avoid flattening provider metadata. Platform details should be
boxed and rendered by edition/provider-aware detail sections.
```swift
enum PlatformContentMetadata: Hashable, Sendable, Codable {
case bedrock(BedrockContentMetadata)
case java(JavaContentMetadata)
case none
}
```
### Access Status
Availability should be richer than local-vs-device.
```swift
enum SourceAccessMode: String, Hashable, Sendable, Codable {
case localFileSystem
case securityScopedLocalFolder
case usbDevice
case networkDevice
case archive
case unknown
}
struct SourceAccessStatus: Hashable, Sendable, Codable {
var availability: SourceAvailability
var mode: SourceAccessMode
var displayName: String
var iconSystemName: String
var statusText: String?
var warningText: String?
}
```
### Event-Driven Scanning
Providers should be able to stream discoveries, metadata, progress, and warnings.
```swift
enum ProviderEvent: Sendable {
case accessStatusChanged(SourceAccessStatus)
case stageUpdated(WorkStage)
case discovered(MinecraftContentItem)
case inspected(MinecraftContentItem)
case warning(ProviderWarning)
}
```
The engine consumes events, updates source state, updates indexes, persists
snapshots, and exposes UI-ready projections.
## Responsibilities
### Engine Owns
- Provider registration/routing.
- Source lifecycle and persistence.
- Scan task ownership, cancellation, and worker limits.
- Cache and snapshot persistence hooks.
- Generic item search, sorting, counts, and projections.
- Generic action dispatch and user-facing error normalization.
### Provider Owns
- Access method details.
- Discovery layout and content markers.
- Metadata parsing.
- Platform relationships.
- Materialization.
- Export formats.
- Provider-specific progress stages and warnings.
- Provider-specific detail metadata.
## Current Fit
Existing app pieces already map to this design:
- `SourceAccessMethod`: provider-like boundary.
- `SourceAccessCoordinator`: provider registry/router.
- `SourceLibrary`: engine/orchestrator.
- `MinecraftSource`: source session and persisted model.
- `SourcePersistenceStore`: persistence.
- `SourceContentIndexer`: generic indexing plus Bedrock-specific relationship
logic that should be split.
- `ContentItemActionService` and `ContentPackageExporter`: action/export layer
that should become capability/provider aware.
The main mismatch is that shared models and UI currently carry Bedrock-specific
types and fields directly.

View File

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