From bb4ef36f4430fc830204524276e9545c29bbdb00 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Tue, 2 Jun 2026 13:42:58 -0500 Subject: [PATCH] Add provider source candidate discovery --- .../Models/Sources/SourceRecord.swift | 23 ++++ .../AppSupport/Scanning/WorldScanner.swift | 125 +++++++++++++++++- .../Services/Sources/Core/SourceLibrary.swift | 97 +++++++++++++- .../Core/SourceAccessCoordinator.swift | 41 ++++++ .../LocalFolder/LocalFolderSourceAccess.swift | 50 ++++++- .../UI/Preview/PreviewFixtures.swift | 4 + .../UI/Root/ContentView.swift | 14 ++ .../UI/Sidebar/SidebarColumnViews.swift | 77 +++++++++++ .../World_Manager_for_MinecraftTests.swift | 33 +++++ docs/provider-architecture-design.md | 22 +++ 10 files changed, 483 insertions(+), 3 deletions(-) diff --git a/World Manager for Minecraft/Models/Sources/SourceRecord.swift b/World Manager for Minecraft/Models/Sources/SourceRecord.swift index 55a898d..f195b64 100644 --- a/World Manager for Minecraft/Models/Sources/SourceRecord.swift +++ b/World Manager for Minecraft/Models/Sources/SourceRecord.swift @@ -64,6 +64,23 @@ nonisolated struct SourceProbeResult: Hashable, Sendable { let warnings: [String] } +nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable { + var providerID: PlatformProviderID + var edition: MinecraftEdition + var sourceRootURL: URL + var displayName: String + var confidence: SourceProbeConfidence + var reason: String + var detectedKinds: Set + + var id: String { + [ + providerID, + sourceRootURL.standardizedFileURL.absoluteString + ].joined(separator: "::") + } +} + nonisolated enum WorkStageState: String, Hashable, Sendable, Codable { case pending case running @@ -101,6 +118,12 @@ nonisolated enum ProviderEvent: Sendable { case warning(ProviderWarning) } +nonisolated enum SourceCandidateEvent: Sendable { + case stageUpdated(WorkStage) + case candidate(SourceCandidate) + case warning(ProviderWarning) +} + nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable { let id: URL var displayName: String diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift index 00ff2af..893e71f 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift @@ -672,6 +672,51 @@ enum JavaContentScanner { ) } + nonisolated static func discoverSourceCandidates( + providerID: PlatformProviderID, + searchRoots: [URL]? = nil, + fileManager: FileManager = .default + ) -> [SourceCandidate] { + let roots = uniqueStandardizedURLs(searchRoots ?? defaultCandidateSearchRoots(fileManager: fileManager)) + .map(\.standardizedFileURL) + .filter { fileManager.fileExists(atPath: $0.path) } + + var candidatesByID: [String: SourceCandidate] = [:] + for root in roots { + let candidateFolders = boundedCandidateFolders(from: root, maxDepth: 4, maxFolderCount: 600, fileManager: fileManager) + for folderURL in candidateFolders { + guard let probe = probeLocalFolder(folderURL, providerID: providerID) else { + continue + } + + let candidate = SourceCandidate( + providerID: probe.providerID, + edition: probe.edition, + sourceRootURL: probe.sourceRootURL, + displayName: probe.displayName, + confidence: probe.confidence, + reason: "Found Java markers near \(root.lastPathComponent)", + detectedKinds: probe.detectedKinds + ) + + if let existingCandidate = candidatesByID[candidate.id], + existingCandidate.confidence >= candidate.confidence { + continue + } + + candidatesByID[candidate.id] = candidate + } + } + + return candidatesByID.values.sorted { + if $0.confidence != $1.confidence { + return $0.confidence > $1.confidence + } + + return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + nonisolated static func discoverItems( in searchRootURL: URL, onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } @@ -885,7 +930,7 @@ enum JavaContentScanner { let children = (try? fileManager.contentsOfDirectory( at: url, includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] + options: [] )) ?? [] candidates.append(contentsOf: children.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true @@ -931,6 +976,84 @@ enum JavaContentScanner { return (score, kinds) } + nonisolated private static func defaultCandidateSearchRoots(fileManager: FileManager) -> [URL] { + let homeURL = fileManager.homeDirectoryForCurrentUser + let applicationSupportURL = homeURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + let documentsURL = homeURL.appendingPathComponent("Documents", isDirectory: true) + + return [ + applicationSupportURL.appendingPathComponent("minecraft", isDirectory: true), + documentsURL.appendingPathComponent("curseforge/minecraft", isDirectory: true), + documentsURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true), + applicationSupportURL.appendingPathComponent("PrismLauncher/instances", isDirectory: true), + applicationSupportURL.appendingPathComponent("MultiMC/instances", isDirectory: true), + applicationSupportURL.appendingPathComponent("PolyMC/instances", isDirectory: true), + applicationSupportURL.appendingPathComponent("com.modrinth.theseus/profiles", isDirectory: true), + applicationSupportURL.appendingPathComponent("ATLauncher/instances", isDirectory: true), + applicationSupportURL.appendingPathComponent("gdlauncher_next/instances", isDirectory: true), + applicationSupportURL.appendingPathComponent("GDLauncher_next/instances", isDirectory: true) + ] + } + + nonisolated private static func boundedCandidateFolders( + from rootURL: URL, + maxDepth: Int, + maxFolderCount: Int, + fileManager: FileManager + ) -> [URL] { + var folders: [URL] = [] + var queue: [(url: URL, depth: Int)] = [(rootURL, 0)] + var seen = Set() + + while !queue.isEmpty && folders.count < maxFolderCount { + let current = queue.removeFirst() + let normalizedURL = current.url.standardizedFileURL + guard seen.insert(normalizedURL.path).inserted else { + continue + } + + folders.append(normalizedURL) + guard current.depth < maxDepth else { + continue + } + + let children = (try? fileManager.contentsOfDirectory( + at: normalizedURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + )) ?? [] + + let childDirectories = children + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + .sorted { lhs, rhs in + lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending + } + + queue.append(contentsOf: childDirectories.map { ($0, current.depth + 1) }) + } + + return folders + } + + nonisolated private static func uniqueStandardizedURLs(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + result.reserveCapacity(urls.count) + + for url in urls { + let standardizedURL = url.standardizedFileURL + guard seen.insert(standardizedURL.path).inserted else { + continue + } + + result.append(standardizedURL) + } + + return result + } + nonisolated private static func collectionSnapshot( for collectionURL: URL, fileManager: FileManager diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index f6d22b4..29d1636 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -6,6 +6,11 @@ import Foundation import OSLog import UniformTypeIdentifiers +enum SourceLibraryCommand: Sendable { + case discoverSourceCandidates + case refreshAllSources +} + @MainActor final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { private static let enrichmentWorkerCount = 4 @@ -28,10 +33,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } } @Published var connectedDevices: [ConnectedDeviceSidebarEntry] = [] + @Published var sourceCandidates: [SourceCandidate] = [] + @Published var isDiscoveringSourceCandidates = false @Published var isRestoringPersistedSources = true private var scanTasks: [URL: Task] = [:] private var automaticSyncTasks: [URL: Task] = [:] + private var candidateDiscoveryTask: Task? private var connectedDeviceRefreshTask: Task? private var localSourceRefreshTask: Task? private let persistenceStore: SourcePersistenceStore @@ -80,6 +88,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer deinit { connectedDeviceRefreshTask?.cancel() localSourceRefreshTask?.cancel() + candidateDiscoveryTask?.cancel() automaticSyncTasks.values.forEach { $0.cancel() } scanTasks.values.forEach { $0.cancel() } } @@ -110,6 +119,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer connectedDeviceRefreshTask = nil localSourceRefreshTask?.cancel() localSourceRefreshTask = nil + candidateDiscoveryTask?.cancel() + candidateDiscoveryTask = nil for task in automaticSyncTasks.values { task.cancel() @@ -137,6 +148,17 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer ) } + func perform(_ command: SourceLibraryCommand) { + switch command { + case .discoverSourceCandidates: + discoverSourceCandidates() + case .refreshAllSources: + for source in visibleSources where source.availability == .available { + startScan(for: source.id, mode: .fullScan) + } + } + } + func addSource(at url: URL) async -> URL { let selectedURL = url.standardizedFileURL let probe = await sourceAccessMethod.probeLocalFolder(selectedURL) @@ -146,6 +168,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer let edition = probe?.edition ?? .bedrock if sources.contains(where: { $0.id == normalizedURL }) { + sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL } updateSource(normalizedURL) { source in if source.bookmarkData == nil { source.bookmarkData = bookmarkData @@ -184,7 +207,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer if let warning = probe?.warnings.first { source.scanDiagnostic = warning } - return addSource(source, shouldPersist: true, shouldScan: true) + let sourceID = addSource(source, shouldPersist: true, shouldScan: true) + sourceCandidates.removeAll { $0.sourceRootURL == sourceID } + return sourceID + } + + func addSource(candidate: SourceCandidate) async -> URL { + await addSource(at: candidate.sourceRootURL) } @discardableResult @@ -238,6 +267,42 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer startScan(for: sourceID, mode: .fullScan) } + func discoverSourceCandidates() { + candidateDiscoveryTask?.cancel() + isDiscoveringSourceCandidates = true + sourceCandidates.removeAll { candidateAlreadyAdded($0) } + + let task = Task { [weak self] in + guard let self else { + return + } + + defer { + self.isDiscoveringSourceCandidates = false + self.candidateDiscoveryTask = nil + } + + do { + for try await event in self.sourceAccessMethod.discoverSourceCandidates() { + guard !Task.isCancelled else { + return + } + + switch event { + case .candidate(let candidate): + self.recordSourceCandidate(candidate) + case .stageUpdated, .warning: + break + } + } + } catch { + return + } + } + + candidateDiscoveryTask = task + } + func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] { try await sourceAccessMethod.listItemContents(for: item, in: source) } @@ -558,6 +623,36 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } } sourceIDByItemID = itemIndex + sourceCandidates.removeAll { candidateAlreadyAdded($0) } + } + + private func recordSourceCandidate(_ candidate: SourceCandidate) { + guard !candidateAlreadyAdded(candidate) else { + return + } + + if let existingIndex = sourceCandidates.firstIndex(where: { $0.id == candidate.id }) { + if candidate.confidence > sourceCandidates[existingIndex].confidence { + sourceCandidates[existingIndex] = candidate + } + } else { + sourceCandidates.append(candidate) + } + + sourceCandidates.sort { + if $0.confidence != $1.confidence { + return $0.confidence > $1.confidence + } + + return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool { + sources.contains { source in + source.id == candidate.sourceRootURL.standardizedFileURL + || source.folderURL == candidate.sourceRootURL.standardizedFileURL + } } @discardableResult diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index 7dd3422..9c2b388 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable { protocol SourceAccessMethod: Sendable { nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? + nonisolated func discoverSourceCandidates() -> AsyncThrowingStream nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability @@ -44,6 +45,12 @@ extension SourceAccessMethod { return nil } + nonisolated func discoverSourceCandidates() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.finish() + } + } + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { SourceAccessDescriptor( accessorIdentifier: accessorIdentifier, @@ -251,6 +258,40 @@ struct SourceAccessCoordinator: SourceAccessMethod { return bestProbe } + nonisolated func discoverSourceCandidates() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let accessMethods = Array(accessMethodsByIdentifier.values) + let task = Task.detached(priority: .userInitiated) { + await withTaskGroup(of: Void.self) { group in + for accessMethod in accessMethods { + group.addTask { + do { + for try await event in accessMethod.discoverSourceCandidates() { + continuation.yield(event) + } + } catch { + continuation.yield( + .warning( + ProviderWarning( + id: "\(accessMethod.accessorIdentifier)-candidate-discovery-failed", + message: "Source discovery failed", + detail: error.localizedDescription + ) + ) + ) + } + } + } + } + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + nonisolated func discoverItems( for source: MinecraftSource, mode: SourceDiscoveryMode, diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index 9b066b8..9872442 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -215,13 +215,61 @@ struct BedrockLocalFolderSourceAccess: SourceAccessMethod { struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder" + private let candidateDiscoveryRoots: [URL]? - nonisolated init() {} + 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( diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index 0ff7781..90affa6 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -262,8 +262,12 @@ struct SidebarColumnPreviewContainer: View { SourcesSidebarView( sources: PreviewFixtures.allSources, connectedDevices: [], + sourceCandidates: [], + isDiscoveringSourceCandidates: false, selection: $selection, addSourceAction: {}, + discoverSourcesAction: {}, + addCandidateSourceAction: { _ in }, addDeviceSourceAction: {}, addConnectedDeviceAction: { _ in }, rescanSourceAction: { _ in }, diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index b8ac0b8..24598c8 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -75,8 +75,14 @@ struct ContentView: View { SourcesSidebarView( sources: library.sidebarSources, connectedDevices: library.connectedDevices, + sourceCandidates: library.sourceCandidates, + isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates, selection: sidebarSelectionBinding, addSourceAction: pickFolder, + discoverSourcesAction: { + library.perform(.discoverSourceCandidates) + }, + addCandidateSourceAction: addCandidateSource(_:), addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addConnectedDeviceAction: addConnectedDeviceSource(from:), rescanSourceAction: { source in @@ -581,6 +587,14 @@ struct ContentView: View { } } + private func addCandidateSource(_ candidate: SourceCandidate) { + Task { + let sourceID = await library.addSource(candidate: candidate) + selectedSidebarSelection = .source(sourceID: sourceID) + selectedItemID = nil + } + } + private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool { let fileURLType = UTType.fileURL.identifier let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) } diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index 84009cd..f8dd205 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -28,8 +28,12 @@ struct SidebarFilter: Identifiable, Hashable { struct SourcesSidebarView: View { let sources: [MinecraftSource] let connectedDevices: [ConnectedDeviceSidebarEntry] + let sourceCandidates: [SourceCandidate] + let isDiscoveringSourceCandidates: Bool @Binding var selection: SidebarSelection? let addSourceAction: () -> Void + let discoverSourcesAction: () -> Void + let addCandidateSourceAction: (SourceCandidate) -> Void let addDeviceSourceAction: () -> Void let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void let rescanSourceAction: (MinecraftSource) -> Void @@ -57,9 +61,36 @@ struct SourcesSidebarView: View { SidebarSourcesSectionHeaderView(title: "Available Devices") } } + + if !sourceCandidates.isEmpty { + Section { + ForEach(sourceCandidates) { candidate in + SourceCandidateRow(candidate: candidate) { + addCandidateSourceAction(candidate) + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + } + } header: { + SidebarSourcesSectionHeaderView(title: "Found Sources") + } + } } .listStyle(.sidebar) .toolbar { + ToolbarItem { + Button(action: discoverSourcesAction) { + if isDiscoveringSourceCandidates { + ProgressView() + .appActivityIndicatorStyle(.small) + } else { + Image(systemName: "magnifyingglass") + } + } + .disabled(isDiscoveringSourceCandidates) + .help("Find Minecraft Sources") + } + ToolbarItem { Button(action: addSourceAction) { Image(systemName: "folder.badge.plus") @@ -120,6 +151,52 @@ struct SourcesSidebarView: View { } } +private struct SourceCandidateRow: View { + let candidate: SourceCandidate + let addAction: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: symbolName) + .foregroundStyle(.secondary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(candidate.displayName) + .lineLimit(1) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + Button(action: addAction) { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .help("Add Source") + } + .padding(.vertical, 4) + } + + private var symbolName: String { + switch candidate.edition { + case .bedrock: + return "folder" + case .java: + return "curlybraces" + } + } + + private var subtitle: String { + let editionName = candidate.edition == .java ? "Java" : "Bedrock" + return "\(editionName) - \(candidate.sourceRootURL.lastPathComponent)" + } +} + private struct SidebarFilterRow: View { let filter: SidebarFilter let isIndented: Bool diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index b842a12..5223476 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -326,6 +326,39 @@ struct World_Manager_for_MinecraftTests { } } + @Test func javaProviderDiscoversSourceCandidatesFromBoundedRoots() async throws { + let fileManager = FileManager.default + let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let instanceRootURL = workingURL + .appendingPathComponent("PrismLauncher/instances/Example Instance/.minecraft", isDirectory: true) + let modURL = instanceRootURL.appendingPathComponent("mods/ExampleMod.jar") + defer { try? fileManager.removeItem(at: workingURL) } + + try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("jar".utf8).write(to: modURL) + try fileManager.createDirectory( + at: instanceRootURL.appendingPathComponent("resourcepacks", isDirectory: true), + withIntermediateDirectories: true + ) + + let access = JavaLocalFolderSourceAccess(candidateDiscoveryRoots: [workingURL]) + var candidates: [SourceCandidate] = [] + + for try await event in access.discoverSourceCandidates() { + if case .candidate(let candidate) = event { + candidates.append(candidate) + } + } + + #expect(candidates.contains { candidate in + candidate.providerID == JavaLocalFolderSourceAccess().accessorIdentifier + && candidate.edition == .java + && candidate.sourceRootURL == instanceRootURL.standardizedFileURL + && candidate.detectedKinds.contains(.mod) + && candidate.detectedKinds.contains(.resourcePack) + }) + } + @Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws { let fileManager = FileManager.default let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/docs/provider-architecture-design.md b/docs/provider-architecture-design.md index bef8cb3..8a67298 100644 --- a/docs/provider-architecture-design.md +++ b/docs/provider-architecture-design.md @@ -186,11 +186,32 @@ enum ProviderEvent: Sendable { The engine consumes events, updates source state, updates indexes, persists snapshots, and exposes UI-ready projections. +### Source Candidate Discovery + +Source candidate discovery is separate from source content discovery. Candidate +discovery answers whether a potential source exists in the current environment; +content discovery scans an accepted source for worlds, packs, mods, and other +items. + +```text +Engine asks providers for source candidates + -> each provider uses its own bounded discovery process + -> providers stream candidate events + -> engine deduplicates and filters already-added sources + -> UI shows suggestions that the user can accept +``` + +For example, the Java local provider can check known macOS launcher roots and +shallow-search likely instance folders, while Bedrock local folders can remain a +no-op and rely on folder picking. Connected-device providers can later emit +device-backed candidates from USB or network discovery. + ## Responsibilities ### Engine Owns - Provider registration/routing. +- Source candidate discovery orchestration and deduplication. - Source lifecycle and persistence. - Scan task ownership, cancellation, and worker limits. - Cache and snapshot persistence hooks. @@ -200,6 +221,7 @@ snapshots, and exposes UI-ready projections. ### Provider Owns - Access method details. +- Source candidate discovery strategy. - Discovery layout and content markers. - Metadata parsing. - Platform relationships.