world-manager/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift

194 lines
7.1 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
struct LocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
nonisolated init() {}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source
return SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
let candidateURL: URL
if case .localFolder(let bookmarkData) = source.origin,
let bookmarkData {
var isStale = false
if let resolvedURL = try? URL(
resolvingBookmarkData: bookmarkData,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) {
candidateURL = resolvedURL.standardizedFileURL
} else {
candidateURL = source.folderURL
}
} else {
candidateURL = source.folderURL
}
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
}
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 {
guard case .localFolder(let bookmarkData) = source.origin else {
throw SourceAccessError.accessFailed(
reason: "No 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()
}
}
if case .reconcile = mode,
let snapshot = source.snapshot {
try discoverItemsByReconcilingCache(
for: source,
snapshot: snapshot,
resolvedURL: resolvedURL,
onDiscovered: onDiscovered
)
return
}
_ = try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return await WorldScanner.enrich(item: item)
}
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return WorldScanner.loadSize(for: item)
}
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
_ = source
let fileManager = FileManager.default
let urls = try fileManager.contentsOfDirectory(
at: item.folderURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
return urls
.map { url in
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
return DirectoryEntry(name: url.lastPathComponent, isDirectory: isDirectory)
}
.sorted { lhs, rhs in
if lhs.isDirectory != rhs.isDirectory {
return lhs.isDirectory && !rhs.isDirectory
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
_ = source
return item.folderURL
}
nonisolated private func discoverItemsByReconcilingCache(
for source: MinecraftSource,
snapshot: SourceSnapshot,
resolvedURL: URL,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) throws {
let currentCollections = Dictionary(
uniqueKeysWithValues: WorldScanner.collectionSnapshots(in: resolvedURL).map { ($0.folderName, $0) }
)
let previousCollections = Dictionary(
uniqueKeysWithValues: snapshot.collectionSnapshots.map { ($0.folderName, $0) }
)
var changedCollectionNames = Set<String>()
for type in MinecraftContentType.allCases {
let collectionName = type.collectionFolderName
let currentFingerprint = currentCollections[collectionName]?.fingerprint
let previousFingerprint = previousCollections[collectionName]?.fingerprint
if currentFingerprint != previousFingerprint {
changedCollectionNames.insert(collectionName)
}
}
let unchangedCollectionNames = Set(currentCollections.keys).subtracting(changedCollectionNames)
var reconciledItems = source.rawItems.filter { item in
guard let collectionName = topLevelCollectionName(for: item, sourceRootURL: resolvedURL) else {
return false
}
return unchangedCollectionNames.contains(collectionName)
}
for type in MinecraftContentType.allCases {
let collectionName = type.collectionFolderName
guard changedCollectionNames.contains(collectionName) else {
continue
}
let collectionURL = resolvedURL.appendingPathComponent(collectionName, isDirectory: true)
let discoveredItems = try WorldScanner.discoverItems(
inCollectionRootURL: collectionURL,
contentType: type
)
reconciledItems.append(contentsOf: discoveredItems)
}
reconciledItems.sort(by: WorldScanner.sortItems)
for item in reconciledItems {
onDiscovered(item)
}
}
nonisolated private func topLevelCollectionName(for item: MinecraftContentItem, sourceRootURL: URL) -> String? {
let relativePath = item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "")
let components = relativePath.split(separator: "/")
return components.first.map(String.init)
}
}