Add provider source candidate discovery
This commit is contained in:
parent
2639bca571
commit
bb4ef36f44
@ -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<MinecraftContentKind>
|
||||
|
||||
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
|
||||
|
||||
@ -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<String>()
|
||||
|
||||
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<String>()
|
||||
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
|
||||
|
||||
@ -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<Void, Never>] = [:]
|
||||
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
||||
private var candidateDiscoveryTask: Task<Void, Never>?
|
||||
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
||||
private var localSourceRefreshTask: Task<Void, Never>?
|
||||
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
|
||||
|
||||
@ -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<SourceCandidateEvent, Error>
|
||||
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<SourceCandidateEvent, Error> {
|
||||
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<SourceCandidateEvent, Error> {
|
||||
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,
|
||||
|
||||
@ -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<SourceCandidateEvent, Error> {
|
||||
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(
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user