// SPDX-FileCopyrightText: 2026 John Burwell and contributors // SPDX-License-Identifier: AGPL-3.0-or-later import Foundation typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess struct BedrockLocalFolderSourceAccess: SourceAccessMethod { nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder" nonisolated init() {} nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { BedrockContentScanner.probeLocalFolder(url, providerID: accessorIdentifier) } nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { _ = source return SourceAccessDescriptor( accessorIdentifier: accessorIdentifier, kind: .localFolder, refreshStrategy: .eagerFullScan ) } 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, 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 { 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() 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) } } struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder" private let candidateDiscoveryRoots: [URL]? nonisolated init(candidateDiscoveryRoots: [URL]? = nil) { self.candidateDiscoveryRoots = candidateDiscoveryRoots } nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier) } nonisolated func discoverSourceCandidates() -> AsyncThrowingStream { AsyncThrowingStream { continuation in let roots = candidateDiscoveryRoots let providerID = accessorIdentifier let task = Task.detached(priority: .utility) { continuation.yield( .stageUpdated( WorkStage( id: "\(providerID)-candidate-discovery", title: "Finding Java sources", detail: nil, state: .running, progress: .indeterminate ) ) ) let candidates = JavaContentScanner.discoverSourceCandidates( providerID: providerID, searchRoots: roots ) for candidate in candidates { continuation.yield(.candidate(candidate)) } continuation.yield( .stageUpdated( WorkStage( id: "\(providerID)-candidate-discovery", title: "Finding Java sources", detail: candidates.isEmpty ? "No Java sources found." : "Found \(candidates.count) Java sources.", state: .succeeded, progress: .indeterminate ) ) ) continuation.finish() } continuation.onTermination = { @Sendable _ in task.cancel() } } } 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 let bookmarkData: Data? switch source.origin { case .javaLocalFolder(let data), .localFolder(let data): bookmarkData = data case .connectedDevice: bookmarkData = nil } if 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 let bookmarkData: Data? switch source.origin { case .javaLocalFolder(let data), .localFolder(let data): bookmarkData = data case .connectedDevice: 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 await 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 let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey]) guard values?.isDirectory == true else { return [] } 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 } }