diff --git a/.gitignore b/.gitignore index e519fa8..fcdc23c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ xcuserdata/ # Swift Package Manager local state .swiftpm/ + +# Example data +exampledata/ diff --git a/World Manager for Minecraft/Models/Sources/SourceRecord.swift b/World Manager for Minecraft/Models/Sources/SourceRecord.swift index 024f6d3..55a898d 100644 --- a/World Manager for Minecraft/Models/Sources/SourceRecord.swift +++ b/World Manager for Minecraft/Models/Sources/SourceRecord.swift @@ -42,6 +42,28 @@ nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable { var warningText: String? } +nonisolated enum SourceProbeConfidence: Int, Comparable, Hashable, Sendable, Codable { + case none = 0 + case weak = 25 + case medium = 50 + case strong = 75 + case exact = 100 + + static func < (lhs: SourceProbeConfidence, rhs: SourceProbeConfidence) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +nonisolated struct SourceProbeResult: Hashable, Sendable { + let providerID: PlatformProviderID + let edition: MinecraftEdition + let confidence: SourceProbeConfidence + let sourceRootURL: URL + let displayName: String + let detectedKinds: Set + let warnings: [String] +} + nonisolated enum WorkStageState: String, Hashable, Sendable, Codable { case pending case running diff --git a/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift b/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift index ceb039f..83adc7c 100644 --- a/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/AppSupport/Export/ContentPackageExporter.swift @@ -37,7 +37,11 @@ enum ContentPackageExporter { try fileManager.removeItem(at: archiveURL) } - try await createArchive(for: item, source: source, at: archiveURL) + if isPortableFileItem(item) { + try copyPortableFileItem(item, to: archiveURL, fileManager: fileManager) + } else { + try await createArchive(for: item, source: source, at: archiveURL) + } return archiveURL } @@ -284,6 +288,28 @@ enum ContentPackageExporter { item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension } + nonisolated private static func isPortableFileItem(_ item: MinecraftContentItem) -> Bool { + guard item.sourceEdition == .java else { + return false + } + + guard let expectedExtension = item.capabilities.portablePackageExtension else { + return false + } + + let values = try? item.folderURL.resourceValues(forKeys: [.isRegularFileKey]) + return values?.isRegularFile == true + && item.folderURL.pathExtension.localizedCaseInsensitiveCompare(expectedExtension) == .orderedSame + } + + nonisolated private static func copyPortableFileItem( + _ item: MinecraftContentItem, + to destinationURL: URL, + fileManager: FileManager + ) throws { + try fileManager.copyItem(at: item.folderURL, to: destinationURL) + } + nonisolated private static func uniqueArchiveURL( in directoryURL: URL, baseName: String, diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/ContentItemFileFacts.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/ContentItemFileFacts.swift index a4ca410..9cf44db 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/ContentItemFileFacts.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/ContentItemFileFacts.swift @@ -25,14 +25,32 @@ struct ContentItemFileFacts: Sendable { self.approximateAgeText = nil } - switch item.contentType { - case .world: - let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true) - self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path) - ? "LevelDB world storage" - : "Flat-file world storage" - case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: - self.storageFormatLabel = "Manifest-based package" + switch item.sourceEdition { + case .bedrock: + switch item.contentType { + case .world: + let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true) + self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path) + ? "LevelDB world storage" + : "Flat-file world storage" + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + self.storageFormatLabel = "Manifest-based package" + } + case .java: + switch item.contentKind { + case .world: + self.storageFormatLabel = "Anvil world storage" + case .mod: + self.storageFormatLabel = "Java mod archive" + case .shaderPack: + self.storageFormatLabel = "Shader pack archive" + case .resourcePack: + self.storageFormatLabel = "Resource pack archive" + case .dataPack: + self.storageFormatLabel = "Data pack archive" + case .behaviorPack, .skinPack, .worldTemplate: + self.storageFormatLabel = "Java content" + } } } } diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift index afdeee2..00ff2af 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift @@ -22,6 +22,49 @@ enum BedrockContentScanner { await packReferenceIndexStore.reset(for: sourceRootURL) } + nonisolated static func probeLocalFolder(_ url: URL, providerID: PlatformProviderID) -> SourceProbeResult? { + let fileManager = FileManager.default + let normalizedURL = url.standardizedFileURL + var detectedKinds = Set() + var score = 0 + + let collectionKinds: [(String, MinecraftContentKind)] = [ + ("minecraftWorlds", .world), + ("behavior_packs", .behaviorPack), + ("resource_packs", .resourcePack), + ("skin_packs", .skinPack), + ("world_templates", .worldTemplate) + ] + + for (folderName, kind) in collectionKinds { + if fileManager.fileExists(atPath: normalizedURL.appendingPathComponent(folderName, isDirectory: true).path) { + detectedKinds.insert(kind) + score += 25 + } + } + + if fileManager.fileExists(atPath: normalizedURL.appendingPathComponent("db", isDirectory: true).path) + || fileManager.fileExists(atPath: normalizedURL.appendingPathComponent("levelname.txt").path) { + detectedKinds.insert(.world) + score += 35 + } + + guard score > 0 else { + return nil + } + + let confidence: SourceProbeConfidence = score >= 50 ? .strong : .medium + return SourceProbeResult( + providerID: providerID, + edition: .bedrock, + confidence: confidence, + sourceRootURL: normalizedURL, + displayName: normalizedURL.lastPathComponent, + detectedKinds: detectedKinds, + warnings: [] + ) + } + nonisolated static func discoverItems( in searchRootURL: URL, onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } @@ -583,6 +626,52 @@ private actor PackReferenceIndexStore { } enum JavaContentScanner { + nonisolated static func probeLocalFolder(_ url: URL, providerID: PlatformProviderID) -> SourceProbeResult? { + let fileManager = FileManager.default + let candidates = localFolderProbeCandidates(for: url.standardizedFileURL, fileManager: fileManager) + let scoredCandidates = candidates.compactMap { candidate -> (url: URL, score: Int, kinds: Set)? in + let score = javaProbeScore(for: candidate, fileManager: fileManager) + guard score.value > 0 else { + return nil + } + + return (candidate, score.value, score.kinds) + } + + guard let best = scoredCandidates.max(by: { lhs, rhs in + if lhs.score != rhs.score { + return lhs.score < rhs.score + } + + return lhs.url.path.count > rhs.url.path.count + }) else { + return nil + } + + let confidence: SourceProbeConfidence + if best.score >= 70 { + confidence = .exact + } else if best.score >= 45 { + confidence = .strong + } else { + confidence = .medium + } + + let warnings = best.url.standardizedFileURL == url.standardizedFileURL ? [] : [ + "Using nested Java instance folder: \(best.url.lastPathComponent)" + ] + + return SourceProbeResult( + providerID: providerID, + edition: .java, + confidence: confidence, + sourceRootURL: best.url.standardizedFileURL, + displayName: best.url.lastPathComponent, + detectedKinds: best.kinds, + warnings: warnings + ) + } + nonisolated static func discoverItems( in searchRootURL: URL, onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } @@ -603,14 +692,50 @@ enum JavaContentScanner { 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 + )) + } + + if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: searchRootURL, fileManager: fileManager) { + discoveredItems.append(contentsOf: try discoverJavaPackages( + in: shaderPacksURL, + contentKind: .shaderPack, + platformType: .shaderPack, + packageExtension: "zip", + fileManager: fileManager + )) + } + + if let modsURL = existingDirectory(named: "mods", in: searchRootURL, fileManager: fileManager) { + discoveredItems.append(contentsOf: try discoverJavaPackages( + in: modsURL, + contentKind: .mod, + platformType: .mod, + packageExtension: "jar", + fileManager: fileManager + )) + } + discoveredItems.sort(by: WorldScanner.sortItems) discoveredItems.forEach(onDiscovered) return discoveredItems } - nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { + nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem { var enrichedItem = item - enrichedItem.displayName = displayName(for: item) + let metadata = JavaContentMetadataReader.metadata(for: item) + enrichedItem.displayName = metadata?.displayName ?? displayName(for: item) + enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata) + if let packMetadata = metadata?.pack { + enrichedItem.platformMetadata = .java(JavaContentMetadata(pack: packMetadata)) + } + enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL) enrichedItem.metadataLoaded = true enrichedItem.previewLoaded = true @@ -620,7 +745,7 @@ enum JavaContentScanner { nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem { var sizedItem = item - sizedItem.sizeBytes = WorldScanner.folderSize(at: item.folderURL, fileManager: .default) + sizedItem.sizeBytes = contentSize(at: item.folderURL, fileManager: .default) sizedItem.sizeLoaded = true return sizedItem } @@ -629,7 +754,10 @@ enum JavaContentScanner { let fileManager = FileManager.default let candidateRoots = [ existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager), - existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager) + existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager), + existingDirectory(named: "datapacks", in: sourceRootURL, fileManager: fileManager), + existingDirectory(named: "shaderpacks", in: sourceRootURL, fileManager: fileManager), + existingDirectory(named: "mods", in: sourceRootURL, fileManager: fileManager) ] return candidateRoots.compactMap { collectionURL in @@ -663,22 +791,53 @@ enum JavaContentScanner { } nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] { - let directories = try WorldScanner.immediateChildDirectories(of: resourcePacksURL, fileManager: fileManager) - return directories.compactMap { packURL in - guard fileManager.fileExists(atPath: packURL.appendingPathComponent("pack.mcmeta").path) else { + try discoverJavaPackages( + in: resourcePacksURL, + contentKind: .resourcePack, + platformType: .resourcePack, + packageExtension: "zip", + fileManager: fileManager, + folderMarker: "pack.mcmeta" + ) + } + + nonisolated private static func discoverJavaPackages( + in collectionURL: URL, + contentKind: MinecraftContentKind, + platformType: JavaContentType, + packageExtension: String, + fileManager: FileManager, + folderMarker: String? = nil + ) throws -> [MinecraftContentItem] { + let children = try fileManager.contentsOfDirectory( + at: collectionURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + + return children.compactMap { childURL in + let values = try? childURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + let isDirectory = values?.isDirectory == true + let isRegularFile = values?.isRegularFile == true + + if isDirectory { + if let folderMarker, + !fileManager.fileExists(atPath: childURL.appendingPathComponent(folderMarker).path) { + return nil + } + } else if isRegularFile { + guard childURL.pathExtension.localizedCaseInsensitiveCompare(packageExtension) == .orderedSame else { + return nil + } + } else { return nil } - return MinecraftContentItem( - folderURL: packURL, - folderName: packURL.lastPathComponent, - contentType: .resourcePack, - sourceEdition: .java, - contentKind: .resourcePack, - platformType: .java(.resourcePack), - collectionRootURL: resourcePacksURL, - capabilities: .java(contentType: .resourcePack), - platformMetadata: .java(JavaContentMetadata()) + return javaContentItem( + url: childURL, + contentKind: contentKind, + platformType: platformType, + collectionRootURL: collectionURL ) } } @@ -692,6 +851,86 @@ enum JavaContentScanner { return directoryURL } + nonisolated private static func javaContentItem( + url: URL, + contentKind: MinecraftContentKind, + platformType: JavaContentType, + collectionRootURL: URL + ) -> MinecraftContentItem { + MinecraftContentItem( + folderURL: url, + folderName: url.lastPathComponent, + contentType: contentKind == .world ? .world : .resourcePack, + sourceEdition: .java, + contentKind: contentKind, + platformType: .java(platformType), + collectionRootURL: collectionRootURL, + displayName: url.deletingPathExtension().lastPathComponent, + capabilities: .java(contentType: platformType), + platformMetadata: .java(JavaContentMetadata()) + ) + } + + nonisolated private static func contentSize(at url: URL, fileManager: FileManager) -> Int64? { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) + if values?.isDirectory == true { + return WorldScanner.folderSize(at: url, fileManager: fileManager) + } + + return values?.fileSize.map(Int64.init) + } + + nonisolated private static func localFolderProbeCandidates(for url: URL, fileManager: FileManager) -> [URL] { + var candidates = [url] + let children = (try? fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + candidates.append(contentsOf: children.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + }) + return candidates + } + + nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set) { + var score = 0 + var kinds = Set() + + if existingDirectory(named: "saves", in: url, fileManager: fileManager) != nil { + kinds.insert(.world) + score += 25 + } + if existingDirectory(named: "resourcepacks", in: url, fileManager: fileManager) != nil { + kinds.insert(.resourcePack) + score += 20 + } + if existingDirectory(named: "datapacks", in: url, fileManager: fileManager) != nil { + kinds.insert(.dataPack) + score += 15 + } + if existingDirectory(named: "shaderpacks", in: url, fileManager: fileManager) != nil { + kinds.insert(.shaderPack) + score += 15 + } + if existingDirectory(named: "mods", in: url, fileManager: fileManager) != nil { + kinds.insert(.mod) + score += 20 + } + if fileManager.fileExists(atPath: url.appendingPathComponent("options.txt").path) + || fileManager.fileExists(atPath: url.appendingPathComponent("launcher_profiles.json").path) + || fileManager.fileExists(atPath: url.appendingPathComponent(".curseclient").path) { + score += 15 + } + if fileManager.fileExists(atPath: url.appendingPathComponent("region", isDirectory: true).path) + && fileManager.fileExists(atPath: url.appendingPathComponent("level.dat").path) { + kinds.insert(.world) + score += 35 + } + + return (score, kinds) + } + nonisolated private static func collectionSnapshot( for collectionURL: URL, fileManager: FileManager @@ -702,35 +941,41 @@ enum JavaContentScanner { let children = (try? fileManager.contentsOfDirectory( at: collectionURL, - includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .contentModificationDateKey, .fileSizeKey], options: [.skipsHiddenFiles] )) ?? [] - let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in - guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + let childSnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?, size: Int?)? in + let values = try? childURL.resourceValues(forKeys: [ + .isDirectoryKey, + .isRegularFileKey, + .contentModificationDateKey, + .fileSizeKey + ]) + guard values?.isDirectory == true || values?.isRegularFile == true else { return nil } - let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - return (childURL.lastPathComponent, modifiedDate) + return (childURL.lastPathComponent, values?.contentModificationDate, values?.fileSize) }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - let childFingerprint = childDirectorySnapshots.map { child in + let childFingerprint = childSnapshots.map { child in [ child.name, - child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil" + child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil", + child.size.map(String.init) ?? "nil" ].joined(separator: "@") }.joined(separator: "|") return CollectionSnapshot( folderName: collectionURL.lastPathComponent, modifiedDate: modifiedDate, - childDirectoryCount: childDirectorySnapshots.count, + childDirectoryCount: childSnapshots.count, fingerprint: [ collectionURL.lastPathComponent, - String(childDirectorySnapshots.count), + String(childSnapshots.count), modifiedDate?.timeIntervalSince1970.formatted() ?? "nil", childFingerprint ].joined(separator: "::") diff --git a/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift b/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift new file mode 100644 index 0000000..a0d8d64 --- /dev/null +++ b/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: 2026 John Burwell and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +nonisolated struct JavaArchiveMetadata: Hashable, Sendable { + var displayName: String? + var pack: JavaPackMetadata? + var iconEntryPath: String? +} + +enum JavaContentMetadataReader { + nonisolated static func metadata(for item: MinecraftContentItem) -> JavaArchiveMetadata? { + let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + return directoryMetadata(for: item) + } + + if values?.isRegularFile == true { + return archiveMetadata(for: item.folderURL, contentKind: item.contentKind) + } + + return nil + } + + nonisolated static func cachedIconURL(for item: MinecraftContentItem, metadata: JavaArchiveMetadata?) async -> URL? { + let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + return await ImageCacheStore.shared.cachedImageURL(for: directoryIconURL(for: item)) + } + + guard + values?.isRegularFile == true, + let metadata, + let iconEntryPath = metadata.iconEntryPath, + let archive = try? ZipArchiveReader(url: item.folderURL), + let entry = archive.entry(named: iconEntryPath), + let data = try? archive.extract(entry) + else { + return nil + } + + return await ImageCacheStore.shared.cachedImageURL( + forRemoteData: data, + cacheKey: "java-archive-icon:\(item.folderURL.standardizedFileURL.path):\(iconEntryPath)", + pathExtension: URL(fileURLWithPath: iconEntryPath).pathExtension + ) + } + + nonisolated private static func directoryMetadata(for item: MinecraftContentItem) -> JavaArchiveMetadata { + let pack = packMetadata(from: item.folderURL.appendingPathComponent("pack.mcmeta")) + let iconURL = directoryIconURL(for: item) + + return JavaArchiveMetadata( + displayName: nil, + pack: pack, + iconEntryPath: iconURL?.lastPathComponent + ) + } + + nonisolated private static func archiveMetadata(for archiveURL: URL, contentKind: MinecraftContentKind) -> JavaArchiveMetadata? { + guard let archive = try? ZipArchiveReader(url: archiveURL) else { + return nil + } + + let pack = packMetadata(from: archive) + let modMetadata = contentKind == .mod ? modMetadata(from: archive) : nil + let iconEntryPath = iconEntryPath( + in: archive, + preferredPath: modMetadata?.iconPath, + contentKind: contentKind + ) + + return JavaArchiveMetadata( + displayName: modMetadata?.displayName, + pack: pack, + iconEntryPath: iconEntryPath + ) + } + + nonisolated private static func directoryIconURL(for item: MinecraftContentItem) -> URL? { + let candidateNames: [String] + switch item.contentKind { + case .mod: + candidateNames = ["icon.png", "logo.png", "mod_logo.png", "catalogue_icon.png", "pack.png"] + case .resourcePack, .dataPack, .shaderPack: + candidateNames = ["pack.png", "icon.png", "logo.png"] + case .world, .behaviorPack, .skinPack, .worldTemplate: + candidateNames = ["icon.png", "pack.png"] + } + + for candidateName in candidateNames { + let candidateURL = item.folderURL.appendingPathComponent(candidateName) + if FileManager.default.fileExists(atPath: candidateURL.path) { + return candidateURL + } + } + + return nil + } + + nonisolated private static func packMetadata(from metadataURL: URL) -> JavaPackMetadata? { + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + + return packMetadata(from: data) + } + + nonisolated private static func packMetadata(from archive: ZipArchiveReader) -> JavaPackMetadata? { + guard + let entry = archive.entry(named: "pack.mcmeta"), + let data = try? archive.extract(entry) + else { + return nil + } + + return packMetadata(from: data) + } + + nonisolated private static func packMetadata(from data: Data) -> JavaPackMetadata? { + guard + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let packObject = jsonObject["pack"] as? [String: Any] + else { + return nil + } + + return JavaPackMetadata( + packFormat: packObject["pack_format"] as? Int, + description: textValue(from: packObject["description"]) + ) + } + + nonisolated private static func modMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? { + if let tomlMetadata = modTOMLMetadata(from: archive) { + return tomlMetadata + } + + if let jsonMetadata = modJSONMetadata(from: archive, entryName: "fabric.mod.json") { + return jsonMetadata + } + + if let jsonMetadata = modJSONMetadata(from: archive, entryName: "quilt.mod.json") { + return jsonMetadata + } + + return nil + } + + nonisolated private static func modTOMLMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? { + let entryNames = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"] + for entryName in entryNames { + guard + let entry = archive.entry(named: entryName), + let data = try? archive.extract(entry), + let text = String(data: data, encoding: .utf8) + else { + continue + } + + let firstModSection = firstTOMLSection(named: "[[mods]]", in: text) + let displayName = tomlStringValue(forKey: "displayName", in: firstModSection) + let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection) + if displayName != nil || logoFile != nil { + return (displayName, logoFile) + } + } + + return nil + } + + nonisolated private static func modJSONMetadata( + from archive: ZipArchiveReader, + entryName: String + ) -> (displayName: String?, iconPath: String?)? { + guard + let entry = archive.entry(named: entryName), + let data = try? archive.extract(entry), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + let iconPath: String? + if let iconString = jsonObject["icon"] as? String { + iconPath = iconString + } else if let icons = jsonObject["icon"] as? [String: String] { + iconPath = icons.sorted { lhs, rhs in lhs.key.localizedStandardCompare(rhs.key) == .orderedDescending }.first?.value + } else { + iconPath = nil + } + + return ( + (jsonObject["name"] as? String)?.nilIfBlank, + iconPath?.nilIfBlank + ) + } + + nonisolated private static func firstTOMLSection(named sectionName: String, in text: String) -> String { + guard let sectionRange = text.range(of: sectionName) else { + return text + } + + let sectionText = text[sectionRange.upperBound...] + if let nextSectionRange = sectionText.range(of: "\n[") { + return String(sectionText[.. String? { + for rawLine in text.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix(key) else { + continue + } + + let parts = line.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2 else { + continue + } + + return parts[1] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + .nilIfBlank + } + + return nil + } + + nonisolated private static func iconEntryPath( + in archive: ZipArchiveReader, + preferredPath: String?, + contentKind: MinecraftContentKind + ) -> String? { + let candidateNames: [String] + switch contentKind { + case .mod: + candidateNames = [preferredPath, "icon.png", "logo.png", "mod_logo.png", "catalogue_icon.png", "pack.png"].compactMap(\.self) + case .resourcePack, .dataPack, .shaderPack: + candidateNames = [preferredPath, "pack.png", "icon.png", "logo.png"].compactMap(\.self) + case .world, .behaviorPack, .skinPack, .worldTemplate: + candidateNames = [preferredPath, "icon.png", "pack.png"].compactMap(\.self) + } + + for candidateName in candidateNames { + if let entry = archive.entry(named: candidateName), !entry.isDirectory { + return entry.path + } + } + + return archive.entries + .filter { !$0.isDirectory && $0.path.localizedCaseInsensitiveContains("icon") && $0.path.hasSuffix(".png") } + .sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending } + .first? + .path + } + + nonisolated private static func textValue(from value: Any?) -> String? { + if let text = value as? String { + return text.nilIfBlank + } + + if let object = value as? [String: Any] { + if let text = object["text"] as? String { + return text.nilIfBlank + } + if let translate = object["translate"] as? String { + return translate.nilIfBlank + } + } + + return nil + } +} + +private extension String { + nonisolated var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 4dd4f01..f6d22b4 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -137,32 +137,53 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer ) } - func addSource(at url: URL) -> URL { - let normalizedURL = url.standardizedFileURL - let bookmarkData = securityScopedBookmarkData(for: normalizedURL) + func addSource(at url: URL) async -> URL { + let selectedURL = url.standardizedFileURL + let probe = await sourceAccessMethod.probeLocalFolder(selectedURL) + let normalizedURL = (probe?.sourceRootURL ?? selectedURL).standardizedFileURL + let bookmarkData = securityScopedBookmarkData(for: normalizedURL) ?? securityScopedBookmarkData(for: selectedURL) + let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier + let edition = probe?.edition ?? .bedrock if sources.contains(where: { $0.id == normalizedURL }) { updateSource(normalizedURL) { source in if source.bookmarkData == nil { source.bookmarkData = bookmarkData } - source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) - source.providerID = source.accessDescriptor.accessorIdentifier + source.accessDescriptor = SourceAccessDescriptor( + accessorIdentifier: providerID, + kind: .localFolder, + refreshStrategy: .eagerFullScan + ) + source.providerID = providerID + source.edition = edition source.capabilities = source.origin.defaultCapabilities + if let probe { + source.displayName = probe.displayName + if let warning = probe.warnings.first { + source.scanDiagnostic = warning + } + } } startScan(for: normalizedURL, mode: .fullScan) return normalizedURL } - let source = MinecraftSource( + var source = MinecraftSource( folderURL: normalizedURL, bookmarkData: bookmarkData, accessDescriptor: SourceAccessDescriptor( - accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier, + accessorIdentifier: providerID, kind: .localFolder, refreshStrategy: .eagerFullScan ) ) + source.providerID = providerID + source.edition = edition + source.displayName = probe?.displayName ?? normalizedURL.lastPathComponent + if let warning = probe?.warnings.first { + source.scanDiagnostic = warning + } return addSource(source, shouldPersist: true, shouldScan: true) } @@ -173,7 +194,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer existingSource.origin = source.origin existingSource.accessDescriptor = source.accessDescriptor existingSource.providerID = source.accessDescriptor.accessorIdentifier - existingSource.edition = source.origin.defaultEdition + existingSource.edition = source.edition existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName) existingSource.availability = source.availability existingSource.capabilities = source.capabilities @@ -188,7 +209,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer var resolvedSource = source resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource) resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier - resolvedSource.edition = resolvedSource.origin.defaultEdition + resolvedSource.edition = source.edition resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName) resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities sources.append(resolvedSource) @@ -441,8 +462,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod) } - func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { - WorldScanner.collectionSnapshots(in: sourceURL) + func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] { + switch edition { + case .bedrock: + return WorldScanner.collectionSnapshots(in: sourceURL) + case .java: + return JavaContentScanner.collectionSnapshots(in: sourceURL) + } } func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { diff --git a/World Manager for Minecraft/Services/Sources/Local/SourceLocalRuntime.swift b/World Manager for Minecraft/Services/Sources/Local/SourceLocalRuntime.swift index ae10516..fb65e7d 100644 --- a/World Manager for Minecraft/Services/Sources/Local/SourceLocalRuntime.swift +++ b/World Manager for Minecraft/Services/Sources/Local/SourceLocalRuntime.swift @@ -12,7 +12,7 @@ protocol LocalSourceRuntimeHosting: AnyObject { func source(withID sourceID: URL) -> MinecraftSource? func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?) - func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] + func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] } enum LocalSourceRuntime { @@ -86,7 +86,7 @@ enum LocalSourceRuntime { if SourceRestoration.needsReconcile( refreshedSource, - currentCollectionSnapshots: host.currentCollectionSnapshots(for:) + currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:) ) { host.queueAutomaticSync( for: sourceID, diff --git a/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift b/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift index c17a121..49c50fd 100644 --- a/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift +++ b/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift @@ -15,7 +15,7 @@ protocol SourcePersistenceHosting: AnyObject { func refreshConnectedDevices() async func refreshLocalSources() async func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?) - func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] + func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String } @@ -125,7 +125,7 @@ enum SourcePersistenceCoordinator { if let refreshReason = SourceRestoration.startupRefreshReason( for: source, persistedRecord: persistedRecordsByID[source.id], - currentCollectionSnapshots: host.currentCollectionSnapshots(for:) + currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:) ) { host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil) } diff --git a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift index dcaca61..4c15cec 100644 --- a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift +++ b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift @@ -16,6 +16,8 @@ enum SourceRestoration { accessDescriptor: record.accessDescriptor, availability: record.availability ) + source.providerID = record.accessDescriptor.accessorIdentifier + source.edition = edition(for: record.accessDescriptor, origin: record.origin) if case .connectedDevice(let device, let container) = source.origin { var repairedDevice = device @@ -104,7 +106,7 @@ enum SourceRestoration { static func startupRefreshReason( for source: MinecraftSource, persistedRecord: PersistedSourceRecord?, - currentCollectionSnapshots: (URL) -> [CollectionSnapshot] + currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> String? { guard source.availability == .available else { return nil @@ -133,7 +135,7 @@ enum SourceRestoration { static func needsReconcile( _ source: MinecraftSource, - currentCollectionSnapshots: (URL) -> [CollectionSnapshot] + currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots) } @@ -152,7 +154,7 @@ enum SourceRestoration { private static func needsRescan( _ record: PersistedSourceRecord, - currentCollectionSnapshots: (URL) -> [CollectionSnapshot] + currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { guard record.accessDescriptor.refreshStrategy == .eagerFullScan else { return record.rawItems.isEmpty @@ -167,15 +169,16 @@ enum SourceRestoration { return true } + let edition = edition(for: record.accessDescriptor, origin: record.origin) return collectionsDiffer( - currentCollectionSnapshots(sourceURL), + currentCollectionSnapshots(sourceURL, edition), persistedCollections: snapshot.collectionSnapshots ) } private static func reconcileIsNeeded( _ source: MinecraftSource, - currentCollectionSnapshots: (URL) -> [CollectionSnapshot] + currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot] ) -> Bool { guard source.accessDescriptor.refreshStrategy == .eagerFullScan else { return source.rawItems.isEmpty @@ -191,11 +194,22 @@ enum SourceRestoration { } return collectionsDiffer( - currentCollectionSnapshots(sourceURL), + currentCollectionSnapshots(sourceURL, source.edition), persistedCollections: snapshot.collectionSnapshots ) } + private static func edition( + for accessDescriptor: SourceAccessDescriptor, + origin: MinecraftSourceOrigin + ) -> MinecraftEdition { + if accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier { + return .java + } + + return origin.defaultEdition + } + private static func collectionsDiffer( _ currentCollections: [CollectionSnapshot], persistedCollections: [CollectionSnapshot] diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index 3382368..7dd3422 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -10,6 +10,7 @@ enum SourceDiscoveryMode: Sendable { protocol SourceAccessMethod: Sendable { nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } + nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability @@ -38,6 +39,11 @@ extension SourceAccessMethod { String(reflecting: Self.self) } + nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { + _ = url + return nil + } + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { SourceAccessDescriptor( accessorIdentifier: accessorIdentifier, @@ -221,6 +227,30 @@ struct SourceAccessCoordinator: SourceAccessMethod { fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).") } + nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { + var bestProbe: SourceProbeResult? + + for accessMethod in accessMethodsByIdentifier.values { + guard let probe = await accessMethod.probeLocalFolder(url) else { + continue + } + + guard probe.confidence > .none else { + continue + } + + if let currentBest = bestProbe { + if probe.confidence > currentBest.confidence { + bestProbe = probe + } + } else { + bestProbe = probe + } + } + + return bestProbe + } + nonisolated func discoverItems( for source: MinecraftSource, mode: SourceDiscoveryMode, diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index 89cc000..9b066b8 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -10,6 +10,10 @@ struct BedrockLocalFolderSourceAccess: SourceAccessMethod { nonisolated init() {} + nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { + BedrockContentScanner.probeLocalFolder(url, providerID: accessorIdentifier) + } + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { _ = source return SourceAccessDescriptor( @@ -214,6 +218,10 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated init() {} + nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? { + JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier) + } + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { _ = source return SourceAccessDescriptor( @@ -226,8 +234,15 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { let candidateURL: URL let mode: SourceAccessMode - if case .javaLocalFolder(let bookmarkData) = source.origin, - let bookmarkData { + let bookmarkData: Data? + switch source.origin { + case .javaLocalFolder(let data), .localFolder(let data): + bookmarkData = data + case .connectedDevice: + bookmarkData = nil + } + + if let bookmarkData { mode = .securityScopedLocalFolder var isStale = false if let resolvedURL = try? URL( @@ -267,7 +282,11 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod { onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws { _ = mode - guard case .javaLocalFolder(let bookmarkData) = source.origin else { + let bookmarkData: Data? + switch source.origin { + case .javaLocalFolder(let data), .localFolder(let data): + bookmarkData = data + case .connectedDevice: throw SourceAccessError.accessFailed( reason: "No Java local-folder access method is configured for this source type." ) @@ -304,7 +323,7 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { _ = source - return JavaContentScanner.enrich(item: item) + return await JavaContentScanner.enrich(item: item) } nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem { @@ -314,6 +333,11 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod { nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] { _ = source + let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values?.isDirectory == true else { + return [] + } + return try await BedrockLocalFolderSourceAccess().listItemContents(for: item, in: source) } diff --git a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift index ad1d02c..31a2f88 100644 --- a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift @@ -197,14 +197,26 @@ struct SourceDetailView: View { } private var contentRows: [(String, String)] { - [ - ("Total Items", source.items.count.formatted(.number)), - ("Worlds", itemCount(for: .world).formatted(.number)), - ("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)), - ("Resource Packs", itemCount(for: .resourcePack).formatted(.number)), - ("Skin Packs", itemCount(for: .skinPack).formatted(.number)), - ("World Templates", itemCount(for: .worldTemplate).formatted(.number)) + var rows = [("Total Items", source.items.count.formatted(.number))] + 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") ] + + for (kind, title) in orderedKinds { + let count = itemCount(for: kind) + if count > 0 || source.edition == .bedrock && bedrockAlwaysDisplayedContentKinds.contains(kind) { + rows.append((title, count.formatted(.number))) + } + } + + return rows } private var locationRows: [(String, String)] { @@ -485,6 +497,14 @@ struct SourceDetailView: View { source.items.filter { $0.contentType == type }.count } + private func itemCount(for kind: MinecraftContentKind) -> Int { + source.items.filter { $0.contentKind == kind }.count + } + + private var bedrockAlwaysDisplayedContentKinds: Set { + [.world, .behaviorPack, .resourcePack, .skinPack, .worldTemplate] + } + @ViewBuilder private func sourceSection(title: String, rows: [(String, String)]) -> some View { VStack(alignment: .leading, spacing: 12) { diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 0ff438e..b8ac0b8 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -574,8 +574,10 @@ struct ContentView: View { } for url in panel.urls { - let sourceID = library.addSource(at: url) - selectSourceIfNeeded(sourceID) + Task { @MainActor in + let sourceID = await library.addSource(at: url) + selectSourceIfNeeded(sourceID) + } } } @@ -596,7 +598,7 @@ struct ContentView: View { } Task { @MainActor in - let sourceID = library.addSource(at: url) + let sourceID = await library.addSource(at: url) selectSourceIfNeeded(sourceID) } } diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index 19a9b2e..b842a12 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -148,8 +148,12 @@ struct World_Manager_for_MinecraftTests { @Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws { let fileManager = FileManager.default let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - let worldURL = rootURL.appendingPathComponent("saves/JavaWorld", isDirectory: true) - let packURL = rootURL.appendingPathComponent("resourcepacks/JavaPack", isDirectory: true) + let instanceURL = rootURL.appendingPathComponent("Better MC [NEOFORGE] BMC5", isDirectory: true) + let worldURL = instanceURL.appendingPathComponent("saves/JavaWorld", isDirectory: true) + let packURL = instanceURL.appendingPathComponent("resourcepacks/JavaPack", isDirectory: true) + let zippedPackURL = instanceURL.appendingPathComponent("resourcepacks/JavaPack.zip") + let shaderPackURL = instanceURL.appendingPathComponent("shaderpacks/Shader.zip") + let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar") defer { try? fileManager.removeItem(at: rootURL) } try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true) @@ -165,17 +169,35 @@ struct World_Manager_for_MinecraftTests { atomically: true, encoding: .utf8 ) + try fileManager.createDirectory(at: zippedPackURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("zip".utf8).write(to: zippedPackURL) + try fileManager.createDirectory(at: shaderPackURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("shader".utf8).write(to: shaderPackURL) + try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("jar".utf8).write(to: modURL) - let source = MinecraftSource( - folderURL: rootURL, - origin: .javaLocalFolder(bookmarkData: nil) - ) let access = SourceAccessCoordinator( accessMethods: [ LocalFolderSourceAccess(), JavaLocalFolderSourceAccess() ] ) + let probe = await access.probeLocalFolder(rootURL) + #expect(probe?.providerID == JavaLocalFolderSourceAccess().accessorIdentifier) + #expect(probe?.sourceRootURL == instanceURL.standardizedFileURL) + #expect(probe?.detectedKinds.contains(.mod) == true) + + var source = MinecraftSource( + folderURL: instanceURL, + origin: .localFolder(bookmarkData: nil), + accessDescriptor: SourceAccessDescriptor( + accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier, + kind: .localFolder, + refreshStrategy: .eagerFullScan + ) + ) + source.edition = .java + source.providerID = JavaLocalFolderSourceAccess().accessorIdentifier var discoveredItems: [MinecraftContentItem] = [] for try await event in access.scanEvents(for: source, mode: .fullScan) { @@ -188,22 +210,155 @@ struct World_Manager_for_MinecraftTests { enrichedItems.append(await access.enrich(item, for: source)) } - #expect(discoveredItems.count == 2) + #expect(discoveredItems.count == 5) #expect(discoveredItems.allSatisfy { $0.sourceEdition == .java }) #expect(discoveredItems.contains { $0.platformType == .java(.world) && $0.capabilities.portablePackageExtension == "zip" }) #expect(discoveredItems.contains { $0.platformType == .java(.resourcePack) && $0.contentType == .resourcePack }) + #expect(discoveredItems.contains { $0.platformType == .java(.shaderPack) && $0.contentKind == .shaderPack }) + #expect(discoveredItems.contains { $0.platformType == .java(.mod) && $0.contentKind == .mod }) #expect(enrichedItems.contains { $0.displayName == "Displayed Java World" }) var indexedSource = source indexedSource.rawItems = enrichedItems let index = SourceContentIndexer.buildIndex(for: indexedSource) #expect(index.displayItemCountsByKind[.world] == 1) - #expect(index.displayItemCountsByKind[.resourcePack] == 1) + #expect(index.displayItemCountsByKind[.resourcePack] == 2) + #expect(index.displayItemCountsByKind[.shaderPack] == 1) + #expect(index.displayItemCountsByKind[.mod] == 1) indexedSource.rawItems = enrichedItems - let snapshot = SourceScanPolicy.buildSnapshot(for: indexedSource, scanRootURL: rootURL) + let snapshot = SourceScanPolicy.buildSnapshot(for: indexedSource, scanRootURL: instanceURL) #expect(snapshot.collectionSnapshots.map(\.folderName).contains("saves")) #expect(snapshot.collectionSnapshots.map(\.folderName).contains("resourcepacks")) + #expect(snapshot.collectionSnapshots.map(\.folderName).contains("shaderpacks")) + #expect(snapshot.collectionSnapshots.map(\.folderName).contains("mods")) + } + + @Test func javaArchiveEnrichmentReadsModMetadataPackMetadataAndIcons() async throws { + let fileManager = FileManager.default + let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let modSourceURL = workingURL.appendingPathComponent("ModSource", isDirectory: true) + let resourceSourceURL = workingURL.appendingPathComponent("ResourceSource", isDirectory: true) + let modArchiveURL = workingURL.appendingPathComponent("ExampleMod.jar") + let resourceArchiveURL = workingURL.appendingPathComponent("ExamplePack.zip") + defer { try? fileManager.removeItem(at: workingURL) } + + try fileManager.createDirectory(at: modSourceURL.appendingPathComponent("META-INF", isDirectory: true), withIntermediateDirectories: true) + try """ + modLoader = "javafml" + loaderVersion = "[1,)" + + [[mods]] + modId = "examplemod" + displayName = "Example Java Mod" + logoFile = "icon.png" + description = "A test mod." + """.write( + to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"), + atomically: true, + encoding: .utf8 + ) + try """ + { + "pack": { + "description": "Example Mod Resources", + "pack_format": 31 + } + } + """.write(to: modSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8) + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: modSourceURL.appendingPathComponent("icon.png")) + try makeArchive(from: modSourceURL, to: modArchiveURL) + + try fileManager.createDirectory(at: resourceSourceURL, withIntermediateDirectories: true) + try """ + { + "pack": { + "description": "Example Resource Pack", + "pack_format": 34 + } + } + """.write(to: resourceSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8) + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: resourceSourceURL.appendingPathComponent("pack.png")) + try makeArchive(from: resourceSourceURL, to: resourceArchiveURL) + + let modItem = MinecraftContentItem( + folderURL: modArchiveURL, + folderName: modArchiveURL.lastPathComponent, + contentType: .resourcePack, + sourceEdition: .java, + contentKind: .mod, + platformType: .java(.mod), + collectionRootURL: workingURL, + capabilities: .java(contentType: .mod), + platformMetadata: .java(JavaContentMetadata()) + ) + let resourceItem = MinecraftContentItem( + folderURL: resourceArchiveURL, + folderName: resourceArchiveURL.lastPathComponent, + contentType: .resourcePack, + sourceEdition: .java, + contentKind: .resourcePack, + platformType: .java(.resourcePack), + collectionRootURL: workingURL, + capabilities: .java(contentType: .resourcePack), + platformMetadata: .java(JavaContentMetadata()) + ) + + let enrichedMod = await JavaContentScanner.enrich(item: modItem) + let enrichedResource = await JavaContentScanner.enrich(item: resourceItem) + + #expect(enrichedMod.displayName == "Example Java Mod") + #expect(enrichedMod.iconURL != nil) + #expect(enrichedMod.hasKnownIcon) + if case .java(let metadata) = enrichedMod.platformMetadata { + #expect(metadata.pack?.description == "Example Mod Resources") + #expect(metadata.pack?.packFormat == 31) + } else { + Issue.record("Expected Java metadata") + } + + #expect(enrichedResource.iconURL != nil) + if case .java(let metadata) = enrichedResource.platformMetadata { + #expect(metadata.pack?.description == "Example Resource Pack") + #expect(metadata.pack?.packFormat == 34) + } else { + Issue.record("Expected Java metadata") + } + } + + @Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let instanceURL = rootURL.appendingPathComponent("Better MC [NEOFORGE] BMC5", isDirectory: true) + let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar") + defer { try? fileManager.removeItem(at: rootURL) } + + try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("jar".utf8).write(to: modURL) + try fileManager.createDirectory( + at: instanceURL.appendingPathComponent("resourcepacks", isDirectory: true), + withIntermediateDirectories: true + ) + + let access = SourceAccessCoordinator( + accessMethods: [ + LocalFolderSourceAccess(), + JavaLocalFolderSourceAccess() + ] + ) + let library = SourceLibrary(sourceAccessMethod: access) + + let sourceID = await library.addSource(at: rootURL) + guard let source = library.source(withID: sourceID) else { + Issue.record("Expected added source") + return + } + + #expect(source.folderURL == instanceURL.standardizedFileURL) + #expect(source.origin.kind == .localFolder) + #expect(source.edition == .java) + #expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier) + #expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier) } @Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws { @@ -1120,6 +1275,106 @@ struct World_Manager_for_MinecraftTests { #expect(restored[0].lastScanDate == legacyRecord.lastScanDate) } + @Test func sourceRestorationPreservesJavaProviderResolvedLocalFolder() async throws { + let sourceURL = URL(fileURLWithPath: "/tmp/JavaInstance", isDirectory: true) + let accessDescriptor = SourceAccessDescriptor( + accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier, + kind: .localFolder, + refreshStrategy: .eagerFullScan + ) + let record = PersistedSourceRecord( + sourceID: sourceURL, + folderURL: sourceURL, + origin: .localFolder(bookmarkData: nil), + accessDescriptor: accessDescriptor, + availability: .available, + bookmarkData: nil, + displayName: "Java Instance", + rawItems: [], + snapshot: nil, + lastScanDate: nil, + needsRepair: false + ) + + let source = SourceRestoration.restoredSource(from: record) { _, _ in "" } + + #expect(source.origin.kind == .localFolder) + #expect(source.edition == .java) + #expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier) + #expect(source.accessDescriptor == accessDescriptor) + } + + @Test func javaRestoredSnapshotDoesNotRequestRefreshWhenUnchanged() async throws { + let fileManager = FileManager.default + let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let modURL = sourceURL.appendingPathComponent("mods/ExampleMod.jar") + defer { try? fileManager.removeItem(at: sourceURL) } + + try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("jar".utf8).write(to: modURL) + + let item = MinecraftContentItem( + folderURL: modURL, + folderName: modURL.lastPathComponent, + contentType: .resourcePack, + sourceEdition: .java, + contentKind: .mod, + platformType: .java(.mod), + collectionRootURL: modURL.deletingLastPathComponent(), + displayName: "ExampleMod", + capabilities: .java(contentType: .mod), + platformMetadata: .java(JavaContentMetadata()) + ) + var source = MinecraftSource( + folderURL: sourceURL, + origin: .localFolder(bookmarkData: nil), + accessDescriptor: SourceAccessDescriptor( + accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier, + kind: .localFolder, + refreshStrategy: .eagerFullScan + ), + availability: .available + ) + source.providerID = JavaLocalFolderSourceAccess().accessorIdentifier + source.edition = .java + SourceRestoration.applyRestoredItemState( + [item], + lastScanDate: Date(timeIntervalSince1970: 1_000), + snapshot: nil, + to: &source + ) + source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: sourceURL) + + let record = PersistedSourceRecord( + sourceID: source.id, + folderURL: source.folderURL, + origin: source.origin, + accessDescriptor: source.accessDescriptor, + availability: source.availability, + bookmarkData: nil, + displayName: source.displayName, + rawItems: source.rawItems, + snapshot: source.snapshot, + lastScanDate: source.lastScanDate, + needsRepair: false + ) + + let refreshReason = SourceRestoration.startupRefreshReason( + for: source, + persistedRecord: record + ) { url, edition in + switch edition { + case .bedrock: + return WorldScanner.collectionSnapshots(in: url) + case .java: + return JavaContentScanner.collectionSnapshots(in: url) + } + } + + #expect(refreshReason == nil) + #expect(source.snapshot?.collectionSnapshots.first?.childDirectoryCount == 1) + } + @Test func connectedDeviceSourceFactoryCreatesStableSyntheticIdentifier() async throws { let device = ConnectedDevice( udid: "00008110-001234560E90001E", diff --git a/docs/provider-architecture-design.md b/docs/provider-architecture-design.md index ee1e086..bef8cb3 100644 --- a/docs/provider-architecture-design.md +++ b/docs/provider-architecture-design.md @@ -51,6 +51,36 @@ UI ## Core Concepts +### Local Folder Intake + +The folder picker should not decide the platform. A picked folder is a local +access root; providers decide whether it contains Bedrock, Java, or another +platform. + +```text +User picks folder + -> provider registry asks local providers to probe it + -> strongest probe chooses provider, edition, and source root + -> source is stored as a local folder with providerID/accessDescriptor + -> scans route through the selected provider +``` + +This keeps filesystem access separate from Minecraft format knowledge. For +example, selecting a wrapper folder that contains one Java modpack instance can +resolve to the nested instance folder while still using local folder access. + +```swift +struct SourceProbeResult { + let providerID: PlatformProviderID + let edition: MinecraftEdition + let confidence: SourceProbeConfidence + let sourceRootURL: URL + let displayName: String + let detectedKinds: Set + let warnings: [String] +} +``` + ### Provider A provider is the unit that knows a platform and access method. A provider can