802 lines
29 KiB
Swift
802 lines
29 KiB
Swift
//
|
|
// SourceLibrary.swift
|
|
// World Manager for Minecraft
|
|
//
|
|
// Created by John Burwell on 2026-05-25.
|
|
//
|
|
|
|
import Combine
|
|
import Foundation
|
|
import OSLog
|
|
|
|
@MainActor
|
|
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting {
|
|
private static let enrichmentWorkerCount = 4
|
|
private static let sizeWorkerCount = 2
|
|
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
|
private static let automaticSyncDebounce: TimeInterval = 0.75
|
|
private static let localSourceRefreshInterval: TimeInterval = 4.0
|
|
private static let connectedDeviceRefreshInterval: TimeInterval = 2.0
|
|
private static let connectedDeviceRefreshIntervalWhileScanning: TimeInterval = 5.0
|
|
private static let usbConnectedDeviceAutoRefreshInterval: TimeInterval = 45.0
|
|
private static let networkConnectedDeviceAutoRefreshInterval: TimeInterval = 120.0
|
|
private static let performanceLogger = Logger(
|
|
subsystem: Bundle.main.bundleIdentifier ?? "WorldManagerForMinecraft",
|
|
category: "ConnectedDevicePerformance"
|
|
)
|
|
|
|
@Published var sources: [MinecraftSource] = []
|
|
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
|
@Published var isRestoringPersistedSources = true
|
|
|
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
|
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
|
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
|
private var localSourceRefreshTask: Task<Void, Never>?
|
|
private let persistenceStore: SourcePersistenceStore
|
|
private let sourceAccessMethod: SourceAccessMethod
|
|
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
|
private let notificationService: ScanNotificationServicing
|
|
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
|
|
var lastMatchedConnectedSourceIDs: Set<URL> = []
|
|
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
|
|
var isShuttingDown = false
|
|
|
|
init(
|
|
persistenceStore: SourcePersistenceStore = .shared,
|
|
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
|
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
|
|
notificationService: ScanNotificationServicing? = nil
|
|
) {
|
|
self.persistenceStore = persistenceStore
|
|
self.sourceAccessMethod = sourceAccessMethod
|
|
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
|
self.notificationService = notificationService ?? ScanNotificationService.shared
|
|
|
|
Task { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
|
}
|
|
|
|
localSourceRefreshTask = Task { [weak self] in
|
|
await self?.runLocalSourceRefreshLoop()
|
|
}
|
|
|
|
if connectedDeviceAccessMethod != nil {
|
|
connectedDeviceRefreshTask = Task { [weak self] in
|
|
await self?.runConnectedDeviceRefreshLoop()
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
connectedDeviceRefreshTask?.cancel()
|
|
localSourceRefreshTask?.cancel()
|
|
automaticSyncTasks.values.forEach { $0.cancel() }
|
|
scanTasks.values.forEach { $0.cancel() }
|
|
}
|
|
|
|
var visibleSources: [MinecraftSource] {
|
|
sources
|
|
}
|
|
|
|
var sidebarSources: [MinecraftSource] {
|
|
visibleSources
|
|
}
|
|
|
|
func shutdown() {
|
|
guard !isShuttingDown else {
|
|
return
|
|
}
|
|
|
|
isShuttingDown = true
|
|
connectedDeviceRefreshTask?.cancel()
|
|
connectedDeviceRefreshTask = nil
|
|
localSourceRefreshTask?.cancel()
|
|
localSourceRefreshTask = nil
|
|
|
|
for task in automaticSyncTasks.values {
|
|
task.cancel()
|
|
}
|
|
automaticSyncTasks.removeAll()
|
|
|
|
for task in scanTasks.values {
|
|
task.cancel()
|
|
}
|
|
scanTasks.removeAll()
|
|
}
|
|
|
|
func shutdownGracefully(timeout: TimeInterval = 1.0) async {
|
|
guard !isShuttingDown else {
|
|
return
|
|
}
|
|
|
|
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
|
|
from: visibleSources,
|
|
using: persistenceStore
|
|
)
|
|
shutdown()
|
|
try? await Task.sleep(for: .seconds(timeout))
|
|
}
|
|
|
|
func addSource(at url: URL) -> URL {
|
|
let normalizedURL = url.standardizedFileURL
|
|
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
|
|
|
|
if sources.contains(where: { $0.id == normalizedURL }) {
|
|
updateSource(normalizedURL) { source in
|
|
if source.bookmarkData == nil {
|
|
source.bookmarkData = bookmarkData
|
|
}
|
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
|
}
|
|
startScan(for: normalizedURL, mode: .fullScan)
|
|
return normalizedURL
|
|
}
|
|
|
|
let source = MinecraftSource(
|
|
folderURL: normalizedURL,
|
|
bookmarkData: bookmarkData,
|
|
accessDescriptor: SourceAccessDescriptor(
|
|
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier,
|
|
kind: .localFolder,
|
|
refreshStrategy: .eagerFullScan
|
|
)
|
|
)
|
|
return addSource(source, shouldPersist: true, shouldScan: true)
|
|
}
|
|
|
|
@discardableResult
|
|
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
|
|
if sources.contains(where: { $0.id == source.id }) {
|
|
updateSource(source.id) { existingSource in
|
|
existingSource.origin = source.origin
|
|
existingSource.accessDescriptor = source.accessDescriptor
|
|
existingSource.availability = source.availability
|
|
if existingSource.bookmarkData == nil {
|
|
existingSource.bookmarkData = source.bookmarkData
|
|
}
|
|
if existingSource.displayName.isEmpty {
|
|
existingSource.displayName = source.displayName
|
|
}
|
|
}
|
|
} else {
|
|
var resolvedSource = source
|
|
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
|
sources.append(resolvedSource)
|
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
|
}
|
|
|
|
if shouldPersist {
|
|
persistSourceIfAvailable(withID: source.id)
|
|
}
|
|
if shouldScan {
|
|
startScan(for: source.id, mode: .fullScan)
|
|
}
|
|
|
|
return source.id
|
|
}
|
|
|
|
func source(withID sourceID: URL) -> MinecraftSource? {
|
|
sources.first(where: { $0.id == sourceID })
|
|
}
|
|
|
|
func rescanSource(withID sourceID: URL) {
|
|
startScan(for: sourceID, mode: .fullScan)
|
|
}
|
|
|
|
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
|
try await sourceAccessMethod.listItemContents(for: item, in: source)
|
|
}
|
|
|
|
func materializeItem(_ item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
|
try await sourceAccessMethod.materializeItem(for: item, in: source)
|
|
}
|
|
|
|
func removeSource(withID sourceID: URL) {
|
|
let removedSource = source(withID: sourceID)
|
|
scanTasks[sourceID]?.cancel()
|
|
scanTasks[sourceID] = nil
|
|
sources.removeAll { $0.id == sourceID }
|
|
SourcePersistenceCoordinator.deletePersistedSource(withID: sourceID, using: persistenceStore)
|
|
if let removedSource {
|
|
purgeCachedArtifacts(for: removedSource)
|
|
}
|
|
}
|
|
|
|
private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) {
|
|
guard !isShuttingDown else {
|
|
return
|
|
}
|
|
|
|
automaticSyncTasks[sourceID]?.cancel()
|
|
automaticSyncTasks[sourceID] = nil
|
|
scanTasks[sourceID]?.cancel()
|
|
|
|
let task = Task { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
await self.scanSource(withID: sourceID, mode: mode)
|
|
}
|
|
|
|
scanTasks[sourceID] = task
|
|
}
|
|
|
|
var hasActiveScan: Bool {
|
|
sources.contains(where: \.isScanning)
|
|
}
|
|
|
|
var hasActiveConnectedDeviceScan: Bool {
|
|
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
|
|
}
|
|
|
|
private func scanSource(withID sourceID: URL, mode: SourceDiscoveryMode) async {
|
|
defer {
|
|
scanTasks[sourceID] = nil
|
|
}
|
|
|
|
guard let source = source(withID: sourceID) else {
|
|
return
|
|
}
|
|
await SourceScanExecutor.execute(
|
|
sourceID: sourceID,
|
|
mode: mode,
|
|
source: source,
|
|
host: self,
|
|
sourceAccessMethod: sourceAccessMethod,
|
|
notificationService: notificationService,
|
|
enrichmentWorkerCount: Self.enrichmentWorkerCount,
|
|
sizeWorkerCount: Self.sizeWorkerCount,
|
|
minimumVisibleScanDuration: Self.minimumVisibleScanDuration
|
|
)
|
|
}
|
|
|
|
func rebuildNormalizedIndex(for sourceID: URL) {
|
|
updateSource(sourceID) { source in
|
|
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
|
|
source.rawItems = rawItems
|
|
let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) })
|
|
|
|
let rawPacks = rawItems.filter {
|
|
$0.contentType == .behaviorPack || $0.contentType == .resourcePack
|
|
}
|
|
let rawWorlds = rawItems.filter { $0.contentType == .world }
|
|
|
|
let packMetadataByItemID = Dictionary(uniqueKeysWithValues: rawPacks.map { item in
|
|
(item.id, packMetadata(for: item, sourceRootURL: source.folderURL))
|
|
})
|
|
|
|
var chosenRepresentativeByIdentity: [PackIdentity: MinecraftContentItem] = [:]
|
|
var allPackItemsByIdentity: [PackIdentity: [MinecraftContentItem]] = [:]
|
|
|
|
for item in rawPacks {
|
|
let metadata = packMetadataByItemID[item.id] ?? packMetadata(for: item, sourceRootURL: source.folderURL)
|
|
let identity = metadata.identity
|
|
allPackItemsByIdentity[identity, default: []].append(item)
|
|
|
|
guard let existing = chosenRepresentativeByIdentity[identity] else {
|
|
chosenRepresentativeByIdentity[identity] = item
|
|
continue
|
|
}
|
|
|
|
if shouldPreferPackItem(item, over: existing) {
|
|
chosenRepresentativeByIdentity[identity] = item
|
|
}
|
|
}
|
|
|
|
let logicalPacks = allPackItemsByIdentity.keys.sorted {
|
|
let lhs = chosenRepresentativeByIdentity[$0]?.displayName ?? ""
|
|
let rhs = chosenRepresentativeByIdentity[$1]?.displayName ?? ""
|
|
let nameOrder = lhs.localizedStandardCompare(rhs)
|
|
if nameOrder != .orderedSame {
|
|
return nameOrder == .orderedAscending
|
|
}
|
|
|
|
return $0.id.localizedStandardCompare($1.id) == .orderedAscending
|
|
}.compactMap { identity -> LogicalPack? in
|
|
guard
|
|
let representativeItem = chosenRepresentativeByIdentity[identity],
|
|
let instances = allPackItemsByIdentity[identity]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let metadata = packMetadataByItemID[representativeItem.id]
|
|
|
|
return LogicalPack(
|
|
id: identity,
|
|
contentType: identity.type,
|
|
displayName: representativeItem.displayName,
|
|
uuid: metadata?.uuid,
|
|
version: metadata?.version,
|
|
representativeItemID: representativeItem.id,
|
|
instanceItemIDs: instances.map(\.id).sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending },
|
|
isSuspicious: identity.isSuspicious
|
|
)
|
|
}
|
|
|
|
var packInstances: [PackInstance] = []
|
|
for logicalPack in logicalPacks {
|
|
for itemID in logicalPack.instanceItemIDs {
|
|
guard let item = rawItemsByID[itemID] else {
|
|
continue
|
|
}
|
|
|
|
packInstances.append(
|
|
PackInstance(
|
|
id: item.id,
|
|
itemID: item.id,
|
|
sourceID: sourceID,
|
|
logicalPackID: logicalPack.id,
|
|
origin: packOrigin(for: item),
|
|
hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id, $0) })
|
|
var worldRelationships: [WorldPackRelationship] = []
|
|
var logicalWorlds: [LogicalWorld] = []
|
|
|
|
for world in rawWorlds {
|
|
var usedPackIDs = Set<PackIdentity>()
|
|
var unresolvedReferences: [ContentPackReference] = []
|
|
|
|
for reference in world.packReferences {
|
|
let referenceIdentity = PackIdentity(
|
|
type: reference.type,
|
|
uuid: reference.uuid,
|
|
version: reference.version,
|
|
fallbackName: reference.name,
|
|
fallbackLocationHint: world.folderName
|
|
)
|
|
let resolvedID = logicalPacksByID[referenceIdentity]?.id
|
|
|
|
if let resolvedID {
|
|
usedPackIDs.insert(resolvedID)
|
|
} else {
|
|
unresolvedReferences.append(reference)
|
|
}
|
|
|
|
worldRelationships.append(
|
|
WorldPackRelationship(
|
|
worldItemID: world.id,
|
|
logicalPackID: resolvedID,
|
|
reference: reference
|
|
)
|
|
)
|
|
}
|
|
|
|
logicalWorlds.append(
|
|
LogicalWorld(
|
|
id: world.id,
|
|
itemID: world.id,
|
|
usedPackIDs: usedPackIDs.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending },
|
|
unresolvedReferences: unresolvedReferences
|
|
)
|
|
)
|
|
}
|
|
|
|
source.logicalPacks = logicalPacks
|
|
source.logicalWorlds = logicalWorlds.sorted {
|
|
guard
|
|
let lhs = source.rawItem(withID: $0.itemID),
|
|
let rhs = source.rawItem(withID: $1.itemID)
|
|
else {
|
|
return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
|
|
}
|
|
|
|
return WorldScanner.sortItems(lhs, rhs)
|
|
}
|
|
source.packInstances = packInstances.sorted {
|
|
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
|
|
}
|
|
source.worldPackRelationships = worldRelationships
|
|
source.displayItems = buildDisplayItems(
|
|
from: rawItems,
|
|
logicalPacks: logicalPacks,
|
|
rawItemsByID: rawItemsByID
|
|
)
|
|
}
|
|
}
|
|
|
|
func logScanStage(
|
|
_ stage: String,
|
|
elapsed: TimeInterval,
|
|
context: String,
|
|
itemCount: Int
|
|
) {
|
|
Self.performanceLogger.log(
|
|
"\(stage, privacy: .public) \(context, privacy: .public) elapsed=\(elapsed, format: .fixed(precision: 3))s items=\(itemCount)"
|
|
)
|
|
}
|
|
|
|
private func logDeviceRefreshStage(
|
|
_ stage: String,
|
|
elapsed: TimeInterval,
|
|
device: ConnectedDevice,
|
|
containerCount: Int,
|
|
error: Error? = nil
|
|
) {
|
|
let transport = device.connection == .usb ? "usb" : "network"
|
|
let errorDescription = error?.localizedDescription ?? ""
|
|
Self.performanceLogger.log(
|
|
"\(stage, privacy: .public) device=\(device.name, privacy: .public) transport=\(transport, privacy: .public) udid=\(device.udid, privacy: .public) elapsed=\(elapsed, format: .fixed(precision: 3))s containers=\(containerCount) error=\(errorDescription, privacy: .public)"
|
|
)
|
|
}
|
|
|
|
func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
|
|
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
|
|
return
|
|
}
|
|
|
|
var source = sources[index]
|
|
mutate(&source)
|
|
sources[index] = source
|
|
}
|
|
|
|
func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) {
|
|
updateSource(sourceID) { source in
|
|
source.displayItems = snapshot.displayItems
|
|
source.rawItems = snapshot.rawItems
|
|
source.logicalPacks = snapshot.logicalPacks
|
|
source.logicalWorlds = snapshot.logicalWorlds
|
|
source.packInstances = snapshot.packInstances
|
|
source.worldPackRelationships = snapshot.worldPackRelationships
|
|
source.indexedItemCount = snapshot.indexedItemCount
|
|
source.indexedDetailCount = snapshot.indexedDetailCount
|
|
source.previewLoadedCount = snapshot.previewLoadedCount
|
|
source.sizeLoadedCount = snapshot.sizeLoadedCount
|
|
source.scanStatus = snapshot.scanStatus
|
|
source.scanProgress = snapshot.scanProgress
|
|
source.isScanning = snapshot.isScanning
|
|
source.previewStageElapsed = snapshot.previewStageElapsed
|
|
source.previewStageDuration = snapshot.previewStageDuration
|
|
source.sizeStageElapsed = snapshot.sizeStageElapsed
|
|
source.sizeStageDuration = snapshot.sizeStageDuration
|
|
source.lastScanDate = snapshot.lastScanDate
|
|
}
|
|
}
|
|
|
|
private func runConnectedDeviceRefreshLoop() async {
|
|
guard let connectedDeviceAccessMethod else {
|
|
return
|
|
}
|
|
|
|
await ConnectedDeviceRuntime.runRefreshLoop(
|
|
on: self,
|
|
refreshInterval: Self.connectedDeviceRefreshInterval,
|
|
refreshIntervalWhileScanning: Self.connectedDeviceRefreshIntervalWhileScanning,
|
|
accessMethod: connectedDeviceAccessMethod
|
|
)
|
|
}
|
|
|
|
private func runLocalSourceRefreshLoop() async {
|
|
await LocalSourceRuntime.runRefreshLoop(
|
|
on: self,
|
|
refreshInterval: Self.localSourceRefreshInterval,
|
|
accessMethod: sourceAccessMethod
|
|
)
|
|
}
|
|
|
|
func refreshLocalSources() async {
|
|
await LocalSourceRuntime.refreshSources(on: self, using: sourceAccessMethod)
|
|
}
|
|
|
|
func refreshConnectedDevices() async {
|
|
guard let connectedDeviceAccessMethod else {
|
|
return
|
|
}
|
|
|
|
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
|
|
}
|
|
|
|
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
|
WorldScanner.collectionSnapshots(in: sourceURL)
|
|
}
|
|
|
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
|
|
connectedDeviceSourceFactory.displayName(for: device, container: container)
|
|
}
|
|
|
|
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
|
sourceAccessMethod.accessDescriptor(for: source)
|
|
}
|
|
|
|
func logConnectedDeviceRefreshStage(
|
|
_ stage: String,
|
|
elapsed: TimeInterval,
|
|
device: ConnectedDevice,
|
|
containerCount: Int,
|
|
error: Error?
|
|
) {
|
|
logDeviceRefreshStage(
|
|
stage,
|
|
elapsed: elapsed,
|
|
device: device,
|
|
containerCount: containerCount,
|
|
error: error
|
|
)
|
|
}
|
|
|
|
func appendRestoredSource(_ source: MinecraftSource) {
|
|
sources.append(source)
|
|
rebuildNormalizedIndex(for: source.id)
|
|
}
|
|
|
|
func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord) {
|
|
updateSource(record.sourceID) { source in
|
|
SourceRestoration.applyRestoredItemState(
|
|
items,
|
|
lastScanDate: record.lastScanDate,
|
|
snapshot: record.snapshot,
|
|
to: &source
|
|
)
|
|
}
|
|
rebuildNormalizedIndex(for: record.sourceID)
|
|
}
|
|
|
|
func sortSourcesByDisplayName() {
|
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
|
}
|
|
|
|
private func buildDisplayItems(
|
|
from rawItems: [MinecraftContentItem],
|
|
logicalPacks: [LogicalPack],
|
|
rawItemsByID: [URL: MinecraftContentItem]
|
|
) -> [MinecraftContentItem] {
|
|
var normalizedItemIDs = Set<URL>()
|
|
var normalizedItems: [MinecraftContentItem] = []
|
|
|
|
for item in rawItems where item.contentType == .world {
|
|
guard normalizedItemIDs.insert(item.id).inserted else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
for logicalPack in logicalPacks {
|
|
guard
|
|
let item = rawItemsByID[logicalPack.representativeItemID],
|
|
normalizedItemIDs.insert(item.id).inserted
|
|
else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate {
|
|
guard normalizedItemIDs.insert(item.id).inserted else {
|
|
continue
|
|
}
|
|
|
|
normalizedItems.append(item)
|
|
}
|
|
|
|
return normalizedItems
|
|
}
|
|
|
|
func persistSourceIfAvailable(withID sourceID: URL) {
|
|
SourcePersistenceCoordinator.persistSourceIfAvailable(
|
|
withID: sourceID,
|
|
on: self,
|
|
using: persistenceStore
|
|
)
|
|
}
|
|
|
|
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) {
|
|
guard !isShuttingDown else {
|
|
return
|
|
}
|
|
|
|
guard let source = source(withID: sourceID), source.availability == .available else {
|
|
return
|
|
}
|
|
|
|
if source.isScanning {
|
|
return
|
|
}
|
|
|
|
let resolvedDebounce = debounce ?? Self.automaticSyncDebounce
|
|
|
|
automaticSyncTasks[sourceID]?.cancel()
|
|
updateSource(sourceID) { source in
|
|
guard !source.isScanning else {
|
|
return
|
|
}
|
|
|
|
source.scanError = nil
|
|
if isCachedAvailabilityDiagnostic(source.scanDiagnostic) {
|
|
source.scanDiagnostic = nil
|
|
}
|
|
source.scanStatus = reason
|
|
source.scanProgress = nil
|
|
}
|
|
|
|
let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan
|
|
|
|
let task = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .seconds(resolvedDebounce))
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
guard let self, !Task.isCancelled else {
|
|
return
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.startScan(for: sourceID, mode: mode)
|
|
}
|
|
}
|
|
|
|
automaticSyncTasks[sourceID] = task
|
|
}
|
|
|
|
private func purgeCachedArtifacts(for source: MinecraftSource) {
|
|
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
|
await sourceAccessMethod.purgeCachedArtifacts(for: source)
|
|
}
|
|
}
|
|
|
|
private func securityScopedBookmarkData(for url: URL) -> Data? {
|
|
try? url.bookmarkData(
|
|
options: [.withSecurityScope],
|
|
includingResourceValuesForKeys: nil,
|
|
relativeTo: nil
|
|
)
|
|
}
|
|
|
|
private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool {
|
|
contentType == .behaviorPack || contentType == .resourcePack
|
|
}
|
|
|
|
@discardableResult
|
|
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
|
|
let previousAvailability = source(withID: sourceID)?.availability ?? .unknown
|
|
let becameAvailable = previousAvailability != .available && newAvailability == .available
|
|
|
|
updateSource(sourceID) { source in
|
|
source.availability = newAvailability
|
|
|
|
guard !source.isScanning else {
|
|
return
|
|
}
|
|
|
|
if newAvailability == .available {
|
|
source.scanError = nil
|
|
if isCachedAvailabilityDiagnostic(source.scanDiagnostic) {
|
|
source.scanDiagnostic = nil
|
|
}
|
|
if becameAvailable || source.scanStatus.isEmpty {
|
|
source.scanStatus = source.indexedItemCount == 0
|
|
? "No Minecraft items found."
|
|
: "Loaded \(source.indexedDetailCount) items."
|
|
}
|
|
} else {
|
|
source.scanError = nil
|
|
source.scanProgress = nil
|
|
source.scanStatus = source.availabilityDisplayText
|
|
source.scanDiagnostic = source.cachedAvailabilityDetailText
|
|
}
|
|
}
|
|
|
|
return (previousAvailability, becameAvailable)
|
|
}
|
|
|
|
private func isCachedAvailabilityDiagnostic(_ diagnostic: String?) -> Bool {
|
|
guard let diagnostic else {
|
|
return false
|
|
}
|
|
|
|
return diagnostic.localizedCaseInsensitiveContains("showing cached results")
|
|
}
|
|
|
|
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
|
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
|
let existingEmbedded = isEmbeddedWorldPack(existing)
|
|
|
|
if candidateEmbedded != existingEmbedded {
|
|
return !candidateEmbedded
|
|
}
|
|
|
|
if candidate.metadataLoaded != existing.metadataLoaded {
|
|
return candidate.metadataLoaded
|
|
}
|
|
|
|
if (candidate.iconURL != nil) != (existing.iconURL != nil) {
|
|
return candidate.iconURL != nil
|
|
}
|
|
|
|
if candidate.previewLoaded != existing.previewLoaded {
|
|
return candidate.previewLoaded
|
|
}
|
|
|
|
if candidate.modifiedDate != existing.modifiedDate {
|
|
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
|
}
|
|
|
|
return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending
|
|
}
|
|
|
|
private func packOrigin(for item: MinecraftContentItem) -> PackSource {
|
|
isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection
|
|
}
|
|
|
|
private func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool {
|
|
item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName)
|
|
}
|
|
|
|
private func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? {
|
|
rawWorlds.first(where: { world in
|
|
packItem.folderURL.path.hasPrefix(world.folderURL.path + "/")
|
|
})?.id
|
|
}
|
|
|
|
private func packMetadata(for item: MinecraftContentItem, sourceRootURL: URL) -> PackMetadata {
|
|
let uuid = item.packUUID
|
|
let version = item.packVersion
|
|
|
|
return PackMetadata(
|
|
uuid: uuid,
|
|
version: version,
|
|
identity: PackIdentity(
|
|
type: item.contentType,
|
|
uuid: uuid,
|
|
version: version,
|
|
fallbackName: item.displayName,
|
|
fallbackLocationHint: relativePathHint(for: item, sourceRootURL: sourceRootURL)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func relativePathHint(for item: MinecraftContentItem, sourceRootURL: URL) -> String {
|
|
item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "")
|
|
}
|
|
}
|
|
|
|
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
|
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
|
let existingEmbedded = isEmbeddedWorldPack(existing)
|
|
|
|
if candidateEmbedded != existingEmbedded {
|
|
return !candidateEmbedded
|
|
}
|
|
|
|
if candidate.metadataLoaded != existing.metadataLoaded {
|
|
return candidate.metadataLoaded
|
|
}
|
|
|
|
if (candidate.iconURL != nil) != (existing.iconURL != nil) {
|
|
return candidate.iconURL != nil
|
|
}
|
|
|
|
if candidate.previewLoaded != existing.previewLoaded {
|
|
return candidate.previewLoaded
|
|
}
|
|
|
|
if candidate.modifiedDate != existing.modifiedDate {
|
|
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
|
}
|
|
|
|
return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending
|
|
}
|
|
|
|
private func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool {
|
|
item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName)
|
|
}
|
|
|
|
private func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? {
|
|
rawWorlds.first(where: { world in
|
|
packItem.folderURL.path.hasPrefix(world.folderURL.path + "/")
|
|
})?.id
|
|
}
|