Add provider-resolved Java local sources

This commit is contained in:
John Burwell 2026-06-02 13:37:10 -05:00
parent 14d9048b57
commit 2639bca571
16 changed files with 1079 additions and 79 deletions

3
.gitignore vendored
View File

@ -18,3 +18,6 @@ xcuserdata/
# Swift Package Manager local state
.swiftpm/
# Example data
exampledata/

View File

@ -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<MinecraftContentKind>
let warnings: [String]
}
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending
case running

View File

@ -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,

View File

@ -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"
}
}
}
}

View File

@ -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<MinecraftContentKind>()
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<MinecraftContentKind>)? 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<MinecraftContentKind>) {
var score = 0
var kinds = Set<MinecraftContentKind>()
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: "::")

View File

@ -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[..<nextSectionRange.lowerBound])
}
return String(sectionText)
}
nonisolated private static func tomlStringValue(forKey key: String, in text: String) -> 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
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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)
}

View File

@ -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]

View File

@ -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,

View File

@ -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)
}

View File

@ -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<MinecraftContentKind> {
[.world, .behaviorPack, .resourcePack, .skinPack, .worldTemplate]
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {

View File

@ -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)
}
}

View File

@ -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",

View File

@ -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<MinecraftContentKind>
let warnings: [String]
}
```
### Provider
A provider is the unit that knows a platform and access method. A provider can