Compare commits

...

6 Commits

15 changed files with 1178 additions and 65 deletions

View File

@ -115,6 +115,8 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
} }
switch selection { switch selection {
case .sourceCandidate, .connectedDevice:
return []
case .source(let sourceID), .allContent(let sourceID): case .source(let sourceID), .allContent(let sourceID):
guard sourceID == id else { guard sourceID == id else {
return [] return []

View File

@ -64,6 +64,23 @@ nonisolated struct SourceProbeResult: Hashable, Sendable {
let warnings: [String] 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 { nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending case pending
case running case running
@ -101,6 +118,12 @@ nonisolated enum ProviderEvent: Sendable {
case warning(ProviderWarning) case warning(ProviderWarning)
} }
nonisolated enum SourceCandidateEvent: Sendable {
case stageUpdated(WorkStage)
case candidate(SourceCandidate)
case warning(ProviderWarning)
}
nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable { nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
let id: URL let id: URL
var displayName: String var displayName: String

View File

@ -672,6 +672,60 @@ 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)
var candidatesForRoot: [SourceCandidate] = []
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
)
candidatesForRoot.append(candidate)
}
for candidate in collapsedCandidates(
candidatesForRoot,
under: root,
providerID: providerID
) {
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( nonisolated static func discoverItems(
in searchRootURL: URL, in searchRootURL: URL,
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
@ -679,47 +733,49 @@ enum JavaContentScanner {
let fileManager = FileManager.default let fileManager = FileManager.default
var discoveredItems: [MinecraftContentItem] = [] var discoveredItems: [MinecraftContentItem] = []
let savesRootURL = existingDirectory( for scanRootURL in contentScanRoots(for: searchRootURL, fileManager: fileManager) {
named: "saves", let savesRootURL = existingDirectory(
in: searchRootURL, named: "saves",
fileManager: fileManager in: scanRootURL,
) ?? searchRootURL
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
discoveredItems.append(contentsOf: worldItems)
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: searchRootURL, fileManager: fileManager) {
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
discoveredItems.append(contentsOf: resourcePackItems)
}
if let dataPacksURL = existingDirectory(named: "datapacks", in: searchRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: dataPacksURL,
contentKind: .dataPack,
platformType: .dataPack,
packageExtension: "zip",
fileManager: fileManager fileManager: fileManager
)) ) ?? scanRootURL
} let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
discoveredItems.append(contentsOf: worldItems)
if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: searchRootURL, fileManager: fileManager) { if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages( let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
in: shaderPacksURL, discoveredItems.append(contentsOf: resourcePackItems)
contentKind: .shaderPack, }
platformType: .shaderPack,
packageExtension: "zip",
fileManager: fileManager
))
}
if let modsURL = existingDirectory(named: "mods", in: searchRootURL, fileManager: fileManager) { if let dataPacksURL = existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages( discoveredItems.append(contentsOf: try discoverJavaPackages(
in: modsURL, in: dataPacksURL,
contentKind: .mod, contentKind: .dataPack,
platformType: .mod, platformType: .dataPack,
packageExtension: "jar", packageExtension: "zip",
fileManager: fileManager fileManager: fileManager
)) ))
}
if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: shaderPacksURL,
contentKind: .shaderPack,
platformType: .shaderPack,
packageExtension: "zip",
fileManager: fileManager
))
}
if let modsURL = existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager) {
discoveredItems.append(contentsOf: try discoverJavaPackages(
in: modsURL,
contentKind: .mod,
platformType: .mod,
packageExtension: "jar",
fileManager: fileManager
))
}
} }
discoveredItems.sort(by: WorldScanner.sortItems) discoveredItems.sort(by: WorldScanner.sortItems)
@ -752,21 +808,32 @@ enum JavaContentScanner {
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] { nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
let fileManager = FileManager.default let fileManager = FileManager.default
let candidateRoots = [ var snapshots: [CollectionSnapshot] = []
existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager), for scanRootURL in contentScanRoots(for: sourceRootURL, fileManager: fileManager) {
existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager), let candidateRoots = [
existingDirectory(named: "datapacks", in: sourceRootURL, fileManager: fileManager), existingDirectory(named: "saves", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "shaderpacks", in: sourceRootURL, fileManager: fileManager), existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "mods", in: sourceRootURL, fileManager: fileManager) existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager),
] existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager),
existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager)
]
return candidateRoots.compactMap { collectionURL in for collectionURL in candidateRoots {
guard let collectionURL else { guard let collectionURL else {
return nil continue
}
if let snapshot = collectionSnapshot(
for: collectionURL,
sourceRootURL: sourceRootURL,
fileManager: fileManager
) {
snapshots.append(snapshot)
}
} }
return collectionSnapshot(for: collectionURL, fileManager: fileManager)
} }
return snapshots
} }
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] { nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
@ -885,7 +952,7 @@ enum JavaContentScanner {
let children = (try? fileManager.contentsOfDirectory( let children = (try? fileManager.contentsOfDirectory(
at: url, at: url,
includingPropertiesForKeys: [.isDirectoryKey], includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles] options: []
)) ?? [] )) ?? []
candidates.append(contentsOf: children.filter { candidates.append(contentsOf: children.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
@ -893,6 +960,40 @@ enum JavaContentScanner {
return candidates return candidates
} }
nonisolated private static func collapsedCandidates(
_ candidates: [SourceCandidate],
under root: URL,
providerID: PlatformProviderID
) -> [SourceCandidate] {
let uniqueCandidates = Dictionary(grouping: candidates, by: \.sourceRootURL).compactMap { _, groupedCandidates in
groupedCandidates.max { lhs, rhs in
lhs.confidence < rhs.confidence
}
}
guard uniqueCandidates.count > 1 else {
return uniqueCandidates
}
let detectedKinds = uniqueCandidates.reduce(into: Set<MinecraftContentKind>()) { result, candidate in
result.formUnion(candidate.detectedKinds)
}
let confidence = uniqueCandidates.map(\.confidence).max() ?? .medium
let standardizedRoot = root.standardizedFileURL
return [
SourceCandidate(
providerID: providerID,
edition: .java,
sourceRootURL: standardizedRoot,
displayName: standardizedRoot.lastPathComponent,
confidence: confidence,
reason: "Found multiple Java sources under \(standardizedRoot.lastPathComponent)",
detectedKinds: detectedKinds
)
]
}
nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set<MinecraftContentKind>) { nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set<MinecraftContentKind>) {
var score = 0 var score = 0
var kinds = Set<MinecraftContentKind>() var kinds = Set<MinecraftContentKind>()
@ -931,8 +1032,108 @@ enum JavaContentScanner {
return (score, kinds) 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 contentScanRoots(for sourceRootURL: URL, fileManager: FileManager) -> [URL] {
let standardizedRoot = sourceRootURL.standardizedFileURL
if javaProbeScore(for: standardizedRoot, fileManager: fileManager).value > 0 {
return [standardizedRoot]
}
let discoveredRoots = boundedCandidateFolders(
from: standardizedRoot,
maxDepth: 4,
maxFolderCount: 600,
fileManager: fileManager
).filter { candidateURL in
candidateURL != standardizedRoot
&& javaProbeScore(for: candidateURL, fileManager: fileManager).value > 0
}
return uniqueStandardizedURLs(discoveredRoots).sorted {
$0.path.localizedStandardCompare($1.path) == .orderedAscending
}
}
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( nonisolated private static func collectionSnapshot(
for collectionURL: URL, for collectionURL: URL,
sourceRootURL: URL,
fileManager: FileManager fileManager: FileManager
) -> CollectionSnapshot? { ) -> CollectionSnapshot? {
guard fileManager.fileExists(atPath: collectionURL.path) else { guard fileManager.fileExists(atPath: collectionURL.path) else {
@ -969,12 +1170,15 @@ enum JavaContentScanner {
].joined(separator: "@") ].joined(separator: "@")
}.joined(separator: "|") }.joined(separator: "|")
let folderName = relativePath(from: sourceRootURL.standardizedFileURL, to: collectionURL.standardizedFileURL)
?? collectionURL.lastPathComponent
return CollectionSnapshot( return CollectionSnapshot(
folderName: collectionURL.lastPathComponent, folderName: folderName,
modifiedDate: modifiedDate, modifiedDate: modifiedDate,
childDirectoryCount: childSnapshots.count, childDirectoryCount: childSnapshots.count,
fingerprint: [ fingerprint: [
collectionURL.lastPathComponent, folderName,
String(childSnapshots.count), String(childSnapshots.count),
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil", modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
childFingerprint childFingerprint
@ -982,6 +1186,16 @@ enum JavaContentScanner {
) )
} }
nonisolated private static func relativePath(from rootURL: URL, to childURL: URL) -> String? {
let rootPath = rootURL.standardizedFileURL.path
let childPath = childURL.standardizedFileURL.path
guard childPath.hasPrefix(rootPath + "/") else {
return nil
}
return String(childPath.dropFirst(rootPath.count + 1))
}
nonisolated private static func displayName(for item: MinecraftContentItem) -> String { nonisolated private static func displayName(for item: MinecraftContentItem) -> String {
guard item.contentKind == .world else { guard item.contentKind == .world else {
return item.folderName return item.folderName

View File

@ -6,6 +6,11 @@ import Foundation
import OSLog import OSLog
import UniformTypeIdentifiers import UniformTypeIdentifiers
enum SourceLibraryCommand: Sendable {
case discoverSourceCandidates
case refreshAllSources
}
@MainActor @MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting {
private static let enrichmentWorkerCount = 4 private static let enrichmentWorkerCount = 4
@ -28,10 +33,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var sourceCandidates: [SourceCandidate] = []
@Published var isDiscoveringSourceCandidates = false
@Published var isRestoringPersistedSources = true @Published var isRestoringPersistedSources = true
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var automaticSyncTasks: [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 connectedDeviceRefreshTask: Task<Void, Never>?
private var localSourceRefreshTask: Task<Void, Never>? private var localSourceRefreshTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore private let persistenceStore: SourcePersistenceStore
@ -80,6 +88,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
deinit { deinit {
connectedDeviceRefreshTask?.cancel() connectedDeviceRefreshTask?.cancel()
localSourceRefreshTask?.cancel() localSourceRefreshTask?.cancel()
candidateDiscoveryTask?.cancel()
automaticSyncTasks.values.forEach { $0.cancel() } automaticSyncTasks.values.forEach { $0.cancel() }
scanTasks.values.forEach { $0.cancel() } scanTasks.values.forEach { $0.cancel() }
} }
@ -92,6 +101,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
visibleSources visibleSources
} }
var sidebarConnectedDevices: [ConnectedDeviceSidebarEntry] {
connectedDevices.filter { entry in
guard entry.matchedSourceID == nil else {
return false
}
return !sources.contains { source in
guard case .connectedDevice(let device, _) = source.origin else {
return false
}
return device.udid == entry.device.udid
}
}
}
func sourceID(forItemID itemID: URL) -> URL? { func sourceID(forItemID itemID: URL) -> URL? {
sourceIDByItemID[itemID] sourceIDByItemID[itemID]
} }
@ -110,6 +135,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
connectedDeviceRefreshTask = nil connectedDeviceRefreshTask = nil
localSourceRefreshTask?.cancel() localSourceRefreshTask?.cancel()
localSourceRefreshTask = nil localSourceRefreshTask = nil
candidateDiscoveryTask?.cancel()
candidateDiscoveryTask = nil
for task in automaticSyncTasks.values { for task in automaticSyncTasks.values {
task.cancel() task.cancel()
@ -137,6 +164,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 { func addSource(at url: URL) async -> URL {
let selectedURL = url.standardizedFileURL let selectedURL = url.standardizedFileURL
let probe = await sourceAccessMethod.probeLocalFolder(selectedURL) let probe = await sourceAccessMethod.probeLocalFolder(selectedURL)
@ -146,6 +184,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
let edition = probe?.edition ?? .bedrock let edition = probe?.edition ?? .bedrock
if sources.contains(where: { $0.id == normalizedURL }) { if sources.contains(where: { $0.id == normalizedURL }) {
sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL }
updateSource(normalizedURL) { source in updateSource(normalizedURL) { source in
if source.bookmarkData == nil { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
@ -184,7 +223,51 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
if let warning = probe?.warnings.first { if let warning = probe?.warnings.first {
source.scanDiagnostic = warning 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 {
let normalizedURL = candidate.sourceRootURL.standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
if sources.contains(where: { $0.id == normalizedURL }) {
updateSource(normalizedURL) { source in
if source.bookmarkData == nil {
source.bookmarkData = bookmarkData
}
source.accessDescriptor = SourceAccessDescriptor(
accessorIdentifier: candidate.providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
source.providerID = candidate.providerID
source.edition = candidate.edition
source.displayName = candidate.displayName
source.capabilities = source.origin.defaultCapabilities
}
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == normalizedURL }
startScan(for: normalizedURL, mode: .fullScan)
return normalizedURL
}
var source = MinecraftSource(
folderURL: normalizedURL,
bookmarkData: bookmarkData,
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: candidate.providerID,
kind: .localFolder,
refreshStrategy: .eagerFullScan
)
)
source.providerID = candidate.providerID
source.edition = candidate.edition
source.displayName = candidate.displayName
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID }
return sourceID
} }
@discardableResult @discardableResult
@ -238,6 +321,42 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
startScan(for: sourceID, mode: .fullScan) 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] { func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
try await sourceAccessMethod.listItemContents(for: item, in: source) try await sourceAccessMethod.listItemContents(for: item, in: source)
} }
@ -558,6 +677,36 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
sourceIDByItemID = itemIndex 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 @discardableResult

View File

@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable {
protocol SourceAccessMethod: Sendable { protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult?
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error>
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
@ -44,6 +45,12 @@ extension SourceAccessMethod {
return nil return nil
} }
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
AsyncThrowingStream { continuation in
continuation.finish()
}
}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
SourceAccessDescriptor( SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier, accessorIdentifier: accessorIdentifier,
@ -251,6 +258,40 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return bestProbe 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( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode, mode: SourceDiscoveryMode,

View File

@ -215,13 +215,61 @@ struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
struct JavaLocalFolderSourceAccess: SourceAccessMethod { struct JavaLocalFolderSourceAccess: SourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder" 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? { nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier) 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 { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source _ = source
return SourceAccessDescriptor( return SourceAccessDescriptor(

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
struct ConnectedDeviceDetailView: View {
let entry: ConnectedDeviceSidebarEntry
let addAction: (() -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(entry.device.name)
.font(.largeTitle.weight(.semibold))
Text("Available connected device")
.foregroundStyle(.secondary)
}
if let addAction {
Button("Add Source") {
addAction()
}
.buttonStyle(.borderedProminent)
}
sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Minecraft Access", rows: minecraftRows)
sourceSection(title: "Technical Details", rows: technicalRows)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(28)
}
}
private var overviewRows: [(String, String)] {
var rows: [(String, String)] = [
("Connection", connectionLabel),
("Trust State", trustStateLabel),
("Availability", entry.hasMinecraftContainer ? "Ready to add" : "Not ready")
]
if let productType = entry.device.productType, !productType.isEmpty {
rows.append(("Product Type", productType))
}
if let osVersion = entry.device.osVersion, !osVersion.isEmpty {
rows.append(("OS Version", osVersion))
}
return rows
}
private var minecraftRows: [(String, String)] {
if let error = entry.discoveryErrorDescription, !error.isEmpty {
return [("Discovery Error", error)]
}
guard let container = entry.minecraftContainer else {
return [("Minecraft Container", "Not found")]
}
var rows: [(String, String)] = [
("Minecraft Container", container.appName),
("App ID", container.appID),
("Access Mode", container.accessMode.rawValue)
]
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
rows.append(("Minecraft Path", relativePath))
}
return rows
}
private var technicalRows: [(String, String)] {
[
("UDID", entry.device.udid),
("Device ID", entry.id)
]
}
private var connectionLabel: String {
switch entry.device.connection {
case .usb:
return "USB"
case .network:
return "Network"
}
}
private var trustStateLabel: String {
switch entry.device.trustState {
case .trusted:
return "Trusted"
case .locked:
return "Locked"
case .untrusted:
return "Untrusted"
case .unavailable:
return "Unavailable"
}
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.appSectionTitleStyle(.section)
VStack(spacing: 0) {
ForEach(rows, id: \.0) { title, value in
detailRow(title: title, value: value)
}
}
.appDetailSectionCard()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.appTextStyle(.fieldLabel)
.frame(width: 150, alignment: .leading)
Text(value)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}

View File

@ -7,6 +7,8 @@ import SwiftUI
struct ItemDetailColumnView: View { struct ItemDetailColumnView: View {
let item: MinecraftContentItem? let item: MinecraftContentItem?
let source: MinecraftSource? let source: MinecraftSource?
let sourceCandidate: SourceCandidate?
let connectedDevice: ConnectedDeviceSidebarEntry?
let showsSourceDetails: Bool let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference] let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference] let resourcePacks: [ContentPackReference]
@ -22,11 +24,13 @@ struct ItemDetailColumnView: View {
let exportAction: () -> Void let exportAction: () -> Void
let revealAction: () -> Void let revealAction: () -> Void
let shareAction: (NSView?) -> Void let shareAction: (NSView?) -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let revealCandidateAction: (SourceCandidate) -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
var body: some View { var body: some View {
Group { Group {
if isEmpty { if let item {
} else if let item {
ItemDetailView( ItemDetailView(
item: item, item: item,
source: source, source: source,
@ -46,6 +50,24 @@ struct ItemDetailColumnView: View {
) )
} else if showsSourceDetails, let source { } else if showsSourceDetails, let source {
SourceDetailView(source: source) SourceDetailView(source: source)
} else if let sourceCandidate {
SourceCandidateDetailView(
candidate: sourceCandidate,
addAction: {
addCandidateSourceAction(sourceCandidate)
},
revealAction: {
revealCandidateAction(sourceCandidate)
}
)
} else if let connectedDevice {
ConnectedDeviceDetailView(
entry: connectedDevice,
addAction: connectedDevice.hasMinecraftContainer ? {
addConnectedDeviceAction(connectedDevice)
} : nil
)
} else if isEmpty {
} else { } else {
Text("Select a world or pack to see details") Text("Select a world or pack to see details")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
struct SourceCandidateDetailView: View {
let candidate: SourceCandidate
let addAction: () -> Void
let revealAction: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(candidate.displayName)
.font(.largeTitle.weight(.semibold))
Text("Found source candidate")
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button("Add Source") {
addAction()
}
.buttonStyle(.borderedProminent)
Button("Reveal in Finder") {
revealAction()
}
.buttonStyle(.bordered)
}
sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Detected Content", rows: contentRows)
sourceSection(title: "Location", rows: locationRows)
sourceSection(title: "Technical Details", rows: technicalRows)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(28)
}
}
private var overviewRows: [(String, String)] {
[
("Edition", editionLabel),
("Provider", providerLabel),
("Confidence", confidenceLabel),
("Reason", candidate.reason)
]
}
private var contentRows: [(String, String)] {
guard !candidate.detectedKinds.isEmpty else {
return [("Detected Kinds", "None")]
}
return [("Detected Kinds", orderedKindLabels.joined(separator: ", "))]
}
private var locationRows: [(String, String)] {
[
("Filesystem Path", candidate.sourceRootURL.path)
]
}
private var technicalRows: [(String, String)] {
[
("Provider ID", candidate.providerID),
("Candidate ID", candidate.id)
]
}
private var editionLabel: String {
switch candidate.edition {
case .bedrock:
return "Bedrock"
case .java:
return "Java"
}
}
private var providerLabel: String {
switch candidate.providerID {
case JavaLocalFolderSourceAccess().accessorIdentifier:
return "Java Local Folder"
case LocalFolderSourceAccess().accessorIdentifier:
return "Bedrock Local Folder"
case AppleMobileDeviceSourceAccess().accessorIdentifier:
return "Bedrock iOS Device"
default:
return candidate.providerID
}
}
private var confidenceLabel: String {
switch candidate.confidence {
case .none:
return "None"
case .weak:
return "Weak"
case .medium:
return "Medium"
case .strong:
return "Strong"
case .exact:
return "Exact"
}
}
private var orderedKindLabels: [String] {
let orderedKinds: [(MinecraftContentKind, String)] = [
(.world, "Worlds"),
(.behaviorPack, "Behavior Packs"),
(.resourcePack, "Resource Packs"),
(.dataPack, "Data Packs"),
(.skinPack, "Skin Packs"),
(.worldTemplate, "World Templates"),
(.shaderPack, "Shader Packs"),
(.mod, "Mods")
]
return orderedKinds.compactMap { kind, label in
candidate.detectedKinds.contains(kind) ? label : nil
}
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.appSectionTitleStyle(.section)
VStack(spacing: 0) {
ForEach(rows, id: \.0) { title, value in
detailRow(title: title, value: value)
}
}
.appDetailSectionCard()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.appTextStyle(.fieldLabel)
.frame(width: 150, alignment: .leading)
Text(value)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}

View File

@ -262,8 +262,12 @@ struct SidebarColumnPreviewContainer: View {
SourcesSidebarView( SourcesSidebarView(
sources: PreviewFixtures.allSources, sources: PreviewFixtures.allSources,
connectedDevices: [], connectedDevices: [],
sourceCandidates: [],
isDiscoveringSourceCandidates: false,
selection: $selection, selection: $selection,
addSourceAction: {}, addSourceAction: {},
discoverSourcesAction: {},
addCandidateSourceAction: { _ in },
addDeviceSourceAction: {}, addDeviceSourceAction: {},
addConnectedDeviceAction: { _ in }, addConnectedDeviceAction: { _ in },
rescanSourceAction: { _ in }, rescanSourceAction: { _ in },
@ -351,6 +355,8 @@ struct ItemDetailColumnPreviewContainer: View {
ItemDetailColumnView( ItemDetailColumnView(
item: PreviewFixtures.featuredWorld, item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource, source: PreviewFixtures.primarySource,
sourceCandidate: nil,
connectedDevice: nil,
showsSourceDetails: false, showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack), behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack), resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -365,7 +371,10 @@ struct ItemDetailColumnPreviewContainer: View {
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
exportAction: {}, exportAction: {},
revealAction: {}, revealAction: {},
shareAction: { _ in } shareAction: { _ in },
addCandidateSourceAction: { _ in },
revealCandidateAction: { _ in },
addConnectedDeviceAction: { _ in }
) )
} }
} }

View File

@ -43,8 +43,10 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty let isEmptyLibrary = library.visibleSources.isEmpty && library.sidebarConnectedDevices.isEmpty && library.sourceCandidates.isEmpty
let resolvedCurrentSource = currentSource let resolvedCurrentSource = currentSource
let resolvedCurrentSourceCandidate = currentSourceCandidate
let resolvedCurrentConnectedDevice = currentConnectedDevice
let currentProjectionRequest = ItemCollectionProjectionRequest( let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection, selection: selectedSidebarSelection,
searchText: searchText, searchText: searchText,
@ -74,9 +76,15 @@ struct ContentView: View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
sources: library.sidebarSources, sources: library.sidebarSources,
connectedDevices: library.connectedDevices, connectedDevices: library.sidebarConnectedDevices,
sourceCandidates: library.sourceCandidates,
isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates,
selection: sidebarSelectionBinding, selection: sidebarSelectionBinding,
addSourceAction: pickFolder, addSourceAction: pickFolder,
discoverSourcesAction: {
library.perform(.discoverSourceCandidates)
},
addCandidateSourceAction: addCandidateSource(_:),
addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:), addConnectedDeviceAction: addConnectedDeviceSource(from:),
rescanSourceAction: { source in rescanSourceAction: { source in
@ -116,6 +124,8 @@ struct ContentView: View {
ItemDetailColumnView( ItemDetailColumnView(
item: resolvedCurrentSelectedItem, item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource, source: resolvedCurrentSource,
sourceCandidate: resolvedCurrentSourceCandidate,
connectedDevice: resolvedCurrentConnectedDevice,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection, showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -148,7 +158,10 @@ struct ContentView: View {
} }
shareItem(item, from: anchorView) shareItem(item, from: anchorView)
} },
addCandidateSourceAction: addCandidateSource(_:),
revealCandidateAction: revealCandidateInFinder(_:),
addConnectedDeviceAction: addConnectedDeviceSource(from:)
) )
.frame(minWidth: 450) .frame(minWidth: 450)
} }
@ -183,7 +196,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, _ in .onChange(of: library.sources.map(\.id)) { _, _ in
syncSelection(with: library.visibleSources.map(\.id)) syncSelection(with: library.visibleSources.map(\.id))
} }
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in .onChange(of: library.sidebarConnectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
syncSelection(with: library.visibleSources.map(\.id)) syncSelection(with: library.visibleSources.map(\.id))
} }
.task(id: currentProjectionRequest) { .task(id: currentProjectionRequest) {
@ -249,6 +262,22 @@ struct ContentView: View {
return library.source(withID: sourceID) return library.source(withID: sourceID)
} }
private var currentSourceCandidate: SourceCandidate? {
guard case .sourceCandidate(let candidateID) = selectedSidebarSelection else {
return nil
}
return library.sourceCandidates.first { $0.id == candidateID }
}
private var currentConnectedDevice: ConnectedDeviceSidebarEntry? {
guard case .connectedDevice(let deviceID) = selectedSidebarSelection else {
return nil
}
return library.sidebarConnectedDevices.first { $0.id == deviceID }
}
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? { private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else { guard let selectedItemID else {
return nil return nil
@ -581,6 +610,18 @@ struct ContentView: View {
} }
} }
private func addCandidateSource(_ candidate: SourceCandidate) {
Task {
let sourceID = await library.addSource(candidate: candidate)
selectedSidebarSelection = .source(sourceID: sourceID)
selectedItemID = nil
}
}
private func revealCandidateInFinder(_ candidate: SourceCandidate) {
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool { private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) } let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
@ -640,8 +681,22 @@ struct ContentView: View {
} }
private func syncSelection(with sourceIDs: [URL]) { private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) { if let selectedSidebarSelection {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) } switch selectedSidebarSelection {
case .sourceCandidate(let candidateID):
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .connectedDevice(let deviceID):
if !library.sidebarConnectedDevices.contains(where: { $0.id == deviceID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .source, .allContent, .contentType, .contentKind:
if let selectedSourceID = selectedSidebarSelection.sourceID,
!sourceIDs.contains(selectedSourceID) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
}
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first { } else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .source(sourceID: firstSourceID) self.selectedSidebarSelection = .source(sourceID: firstSourceID)
} }

View File

@ -77,6 +77,10 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate:
return "Source Candidate"
case .connectedDevice:
return "Connected Device"
case .source, .allContent: case .source, .allContent:
return "All Items" return "All Items"
case .contentType(_, let contentType): case .contentType(_, let contentType):
@ -88,6 +92,8 @@ enum ItemCollectionProjector {
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String { nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate), .some(.connectedDevice):
return "Search Library"
case .some(.source): case .some(.source):
return "Search \(source?.displayName ?? "Library")" return "Search \(source?.displayName ?? "Library")"
case .some(.allContent): case .some(.allContent):
@ -103,6 +109,10 @@ enum ItemCollectionProjector {
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String { nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate):
return "Source Candidate"
case .some(.connectedDevice):
return "Connected Device"
case .some(.source): case .some(.source):
return "Library" return "Library"
case .some(.allContent): case .some(.allContent):
@ -122,6 +132,8 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate, .connectedDevice:
return "items"
case .source, .allContent: case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items" return scopedItemCount == 1 ? "item" : "items"
case .contentType(_, let contentType): case .contentType(_, let contentType):

View File

@ -5,14 +5,18 @@ import SwiftUI
enum SidebarSelection: Hashable, Sendable { enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL) case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case connectedDevice(deviceID: String)
case allContent(sourceID: URL) case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType) case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind) case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL { var sourceID: URL? {
switch self { switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _): case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID return sourceID
case .sourceCandidate, .connectedDevice:
return nil
} }
} }
} }
@ -28,8 +32,12 @@ struct SidebarFilter: Identifiable, Hashable {
struct SourcesSidebarView: View { struct SourcesSidebarView: View {
let sources: [MinecraftSource] let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry] let connectedDevices: [ConnectedDeviceSidebarEntry]
let sourceCandidates: [SourceCandidate]
let isDiscoveringSourceCandidates: Bool
@Binding var selection: SidebarSelection? @Binding var selection: SidebarSelection?
let addSourceAction: () -> Void let addSourceAction: () -> Void
let discoverSourcesAction: () -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let addDeviceSourceAction: () -> Void let addDeviceSourceAction: () -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void let rescanSourceAction: (MinecraftSource) -> Void
@ -57,9 +65,43 @@ struct SourcesSidebarView: View {
SidebarSourcesSectionHeaderView(title: "Available Devices") SidebarSourcesSectionHeaderView(title: "Available Devices")
} }
} }
if !sourceCandidates.isEmpty {
Section {
ForEach(sourceCandidates) { candidate in
SourceCandidateRow(
candidate: candidate,
onSelect: {
selection = .sourceCandidate(candidateID: candidate.id)
},
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
} header: {
SidebarSourcesSectionHeaderView(title: "Found Sources")
}
}
} }
.listStyle(.sidebar) .listStyle(.sidebar)
.toolbar { .toolbar {
ToolbarItem {
Button(action: discoverSourcesAction) {
if isDiscoveringSourceCandidates {
ProgressView()
.appActivityIndicatorStyle(.small)
} else {
Image(systemName: "magnifyingglass")
}
}
.disabled(isDiscoveringSourceCandidates)
.help("Find Minecraft Sources")
}
ToolbarItem { ToolbarItem {
Button(action: addSourceAction) { Button(action: addSourceAction) {
Image(systemName: "folder.badge.plus") Image(systemName: "folder.badge.plus")
@ -111,15 +153,68 @@ struct SourcesSidebarView: View {
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View { private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
ConnectedDeviceRow( ConnectedDeviceRow(
entry: entry, entry: entry,
onSelect: {
selection = .connectedDevice(deviceID: entry.id)
},
addAction: entry.hasMinecraftContainer ? { addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry) addConnectedDeviceAction(entry)
} : nil } : nil
) )
.tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8)) .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
} }
} }
private struct SourceCandidateRow: View {
let candidate: SourceCandidate
let onSelect: () -> Void
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) {
Text("Add")
}
.appMiniProminentButton()
.help("Add Source")
}
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.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 { private struct SidebarFilterRow: View {
let filter: SidebarFilter let filter: SidebarFilter
let isIndented: Bool let isIndented: Bool
@ -305,6 +400,7 @@ private struct CircularScanProgressView: View {
private struct ConnectedDeviceRow: View { private struct ConnectedDeviceRow: View {
let entry: ConnectedDeviceSidebarEntry let entry: ConnectedDeviceSidebarEntry
let onSelect: () -> Void
let addAction: (() -> Void)? let addAction: (() -> Void)?
var body: some View { var body: some View {
@ -334,6 +430,8 @@ private struct ConnectedDeviceRow: View {
} }
} }
.opacity(addAction == nil ? 0.68 : 1) .opacity(addAction == nil ? 0.68 : 1)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
} }
private var iconName: String { private var iconName: String {

View File

@ -326,6 +326,135 @@ 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 javaProviderCollapsesNestedSourceCandidatesToSearchRoot() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
for instanceURL in [firstInstanceURL, secondInstanceURL] {
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
}
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
searchRoots: [workingURL]
)
#expect(candidates.count == 1)
#expect(candidates.first?.sourceRootURL == workingURL.standardizedFileURL)
#expect(candidates.first?.displayName == workingURL.lastPathComponent)
#expect(candidates.first?.detectedKinds.contains(.mod) == true)
}
@Test func javaAggregateRootDiscoversNestedInstanceItems() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(
at: firstInstanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: firstInstanceURL.appendingPathComponent("mods/ExampleMod.jar"))
try fileManager.createDirectory(
at: secondInstanceURL.appendingPathComponent("resourcepacks", isDirectory: true),
withIntermediateDirectories: true
)
try Data("zip".utf8).write(to: secondInstanceURL.appendingPathComponent("resourcepacks/ExamplePack.zip"))
let items = try JavaContentScanner.discoverItems(in: workingURL)
let snapshots = JavaContentScanner.collectionSnapshots(in: workingURL)
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
#expect(items.contains { $0.contentKind == .resourcePack && $0.folderName == "ExamplePack.zip" })
#expect(snapshots.map(\.folderName).contains("a/b/c/mods"))
#expect(snapshots.map(\.folderName).contains("a/e/f/resourcepacks"))
}
@Test func sourceLibraryAddSourceCandidatePreservesJavaAggregateProvider() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let instanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
let modURL = instanceURL.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)
let candidate = SourceCandidate(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
edition: .java,
sourceRootURL: workingURL,
displayName: workingURL.lastPathComponent,
confidence: .strong,
reason: "Found multiple Java sources",
detectedKinds: [.mod]
)
let access = SourceAccessCoordinator(
accessMethods: [
LocalFolderSourceAccess(),
JavaLocalFolderSourceAccess()
]
)
let library = SourceLibrary(sourceAccessMethod: access)
let sourceID = await library.addSource(candidate: candidate)
guard let source = library.source(withID: sourceID) else {
Issue.record("Expected added source")
return
}
#expect(source.folderURL == workingURL.standardizedFileURL)
#expect(source.edition == .java)
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
let items = try JavaContentScanner.discoverItems(in: source.folderURL)
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
}
@Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws { @Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws {
let fileManager = FileManager.default let fileManager = FileManager.default
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)

View File

@ -186,11 +186,32 @@ enum ProviderEvent: Sendable {
The engine consumes events, updates source state, updates indexes, persists The engine consumes events, updates source state, updates indexes, persists
snapshots, and exposes UI-ready projections. 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 ## Responsibilities
### Engine Owns ### Engine Owns
- Provider registration/routing. - Provider registration/routing.
- Source candidate discovery orchestration and deduplication.
- Source lifecycle and persistence. - Source lifecycle and persistence.
- Scan task ownership, cancellation, and worker limits. - Scan task ownership, cancellation, and worker limits.
- Cache and snapshot persistence hooks. - Cache and snapshot persistence hooks.
@ -200,6 +221,7 @@ snapshots, and exposes UI-ready projections.
### Provider Owns ### Provider Owns
- Access method details. - Access method details.
- Source candidate discovery strategy.
- Discovery layout and content markers. - Discovery layout and content markers.
- Metadata parsing. - Metadata parsing.
- Platform relationships. - Platform relationships.