// 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 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" 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 } }