From 9ec6b905bc1ec202852b9c5bf02a4bf96c014175 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Tue, 2 Jun 2026 16:50:01 -0500 Subject: [PATCH] Deduplicate source paths case-insensitively --- .../Models/Sources/SourceRecord.swift | 10 +++- .../AppSupport/Scanning/WorldScanner.swift | 6 +- .../Services/Sources/Core/SourceLibrary.swift | 56 ++++++++++++------- .../World_Manager_for_MinecraftTests.swift | 30 ++++++++++ 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/World Manager for Minecraft/Models/Sources/SourceRecord.swift b/World Manager for Minecraft/Models/Sources/SourceRecord.swift index f195b64..705dedb 100644 --- a/World Manager for Minecraft/Models/Sources/SourceRecord.swift +++ b/World Manager for Minecraft/Models/Sources/SourceRecord.swift @@ -76,11 +76,19 @@ nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable { var id: String { [ providerID, - sourceRootURL.standardizedFileURL.absoluteString + sourceIdentityKey(for: sourceRootURL) ].joined(separator: "::") } } +nonisolated func sourceIdentityKey(for url: URL) -> String { + if url.isFileURL { + return url.standardizedFileURL.resolvingSymlinksInPath().path.lowercased() + } + + return url.standardized.absoluteString.lowercased() +} + nonisolated enum WorkStageState: String, Hashable, Sendable, Codable { case pending case running diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift index 543dff4..98c4887 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift @@ -965,7 +965,7 @@ enum JavaContentScanner { under root: URL, providerID: PlatformProviderID ) -> [SourceCandidate] { - let uniqueCandidates = Dictionary(grouping: candidates, by: \.sourceRootURL).compactMap { _, groupedCandidates in + let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.sourceRootURL) }).compactMap { _, groupedCandidates in groupedCandidates.max { lhs, rhs in lhs.confidence < rhs.confidence } @@ -1066,7 +1066,7 @@ enum JavaContentScanner { while !queue.isEmpty && folders.count < maxFolderCount { let current = queue.removeFirst() let normalizedURL = current.url.standardizedFileURL - guard seen.insert(normalizedURL.path).inserted else { + guard seen.insert(sourceIdentityKey(for: normalizedURL)).inserted else { continue } @@ -1121,7 +1121,7 @@ enum JavaContentScanner { for url in urls { let standardizedURL = url.standardizedFileURL - guard seen.insert(standardizedURL.path).inserted else { + guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else { continue } diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index f944374..6f32eb8 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -183,9 +183,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier let edition = probe?.edition ?? .bedrock - if sources.contains(where: { $0.id == normalizedURL }) { - sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL } - updateSource(normalizedURL) { source in + if let existingSourceID = existingSourceID(matching: normalizedURL) { + sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) } + updateSource(existingSourceID) { source in if source.bookmarkData == nil { source.bookmarkData = bookmarkData } @@ -204,8 +204,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } } } - startScan(for: normalizedURL, mode: .fullScan) - return normalizedURL + startScan(for: existingSourceID, mode: .fullScan) + return existingSourceID } var source = MinecraftSource( @@ -224,7 +224,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer source.scanDiagnostic = warning } let sourceID = addSource(source, shouldPersist: true, shouldScan: true) - sourceCandidates.removeAll { $0.sourceRootURL == sourceID } + sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: sourceID) } return sourceID } @@ -232,8 +232,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer let normalizedURL = candidate.sourceRootURL.standardizedFileURL let bookmarkData = securityScopedBookmarkData(for: normalizedURL) - if sources.contains(where: { $0.id == normalizedURL }) { - updateSource(normalizedURL) { source in + if let existingSourceID = existingSourceID(matching: normalizedURL) { + updateSource(existingSourceID) { source in if source.bookmarkData == nil { source.bookmarkData = bookmarkData } @@ -247,9 +247,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer 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 + removeSourceCandidates(matching: candidate, sourceID: existingSourceID) + startScan(for: existingSourceID, mode: .fullScan) + return existingSourceID } var source = MinecraftSource( @@ -266,14 +266,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer source.displayName = candidate.displayName let sourceID = addSource(source, shouldPersist: true, shouldScan: true) - sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID } + removeSourceCandidates(matching: candidate, sourceID: sourceID) return sourceID } @discardableResult func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL { - if sources.contains(where: { $0.id == source.id }) { - updateSource(source.id) { existingSource in + if let existingSourceID = existingSourceID(matching: source.id) { + updateSource(existingSourceID) { existingSource in existingSource.origin = source.origin existingSource.accessDescriptor = source.accessDescriptor existingSource.providerID = source.accessDescriptor.accessorIdentifier @@ -300,13 +300,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } if shouldPersist { - persistSourceIfAvailable(withID: source.id) + persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id) } if shouldScan { - startScan(for: source.id, mode: .fullScan) + startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan) } - return source.id + return existingSourceID(matching: source.id) ?? source.id } func source(withID sourceID: URL) -> MinecraftSource? { @@ -704,8 +704,26 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool { sources.contains { source in - source.id == candidate.sourceRootURL.standardizedFileURL - || source.folderURL == candidate.sourceRootURL.standardizedFileURL + sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL) + || sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL) + } + } + + private func existingSourceID(matching url: URL) -> URL? { + let identity = sourceIdentityKey(for: url) + return sources.first { source in + sourceIdentityKey(for: source.id) == identity + || sourceIdentityKey(for: source.folderURL) == identity + }?.id + } + + private func removeSourceCandidates(matching candidate: SourceCandidate, sourceID: URL) { + let candidateIdentity = sourceIdentityKey(for: candidate.sourceRootURL) + let sourceIdentity = sourceIdentityKey(for: sourceID) + sourceCandidates.removeAll { + $0.id == candidate.id + || sourceIdentityKey(for: $0.sourceRootURL) == candidateIdentity + || sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentity } } diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index 08ee71e..8529272 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -385,6 +385,36 @@ struct World_Manager_for_MinecraftTests { #expect(candidates.first?.detectedKinds.contains(.mod) == true) } + @Test func javaProviderDeduplicatesCaseVariantSourceRoots() async throws { + let fileManager = FileManager.default + let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let upperRootURL = workingURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true) + let lowerRootURL = workingURL.appendingPathComponent("curseforge/minecraft", isDirectory: true) + let firstInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleOne", isDirectory: true) + let secondInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleTwo", 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")) + } + + guard fileManager.fileExists(atPath: lowerRootURL.path) else { + return + } + + let candidates = JavaContentScanner.discoverSourceCandidates( + providerID: JavaLocalFolderSourceAccess().accessorIdentifier, + searchRoots: [upperRootURL, lowerRootURL] + ) + + #expect(candidates.count == 1) + #expect(sourceIdentityKey(for: candidates[0].sourceRootURL) == sourceIdentityKey(for: upperRootURL)) + } + @Test func javaAggregateRootDiscoversNestedInstanceItems() async throws { let fileManager = FileManager.default let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)