Add provider-resolved Java local sources
This commit is contained in:
parent
14d9048b57
commit
2639bca571
3
.gitignore
vendored
3
.gitignore
vendored
@ -18,3 +18,6 @@ xcuserdata/
|
|||||||
|
|
||||||
# Swift Package Manager local state
|
# Swift Package Manager local state
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
|
||||||
|
# Example data
|
||||||
|
exampledata/
|
||||||
|
|||||||
@ -42,6 +42,28 @@ nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable {
|
|||||||
var warningText: String?
|
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 {
|
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
|
||||||
case pending
|
case pending
|
||||||
case running
|
case running
|
||||||
|
|||||||
@ -37,7 +37,11 @@ enum ContentPackageExporter {
|
|||||||
try fileManager.removeItem(at: archiveURL)
|
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
|
return archiveURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +288,28 @@ enum ContentPackageExporter {
|
|||||||
item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension
|
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(
|
nonisolated private static func uniqueArchiveURL(
|
||||||
in directoryURL: URL,
|
in directoryURL: URL,
|
||||||
baseName: String,
|
baseName: String,
|
||||||
|
|||||||
@ -25,14 +25,32 @@ struct ContentItemFileFacts: Sendable {
|
|||||||
self.approximateAgeText = nil
|
self.approximateAgeText = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch item.contentType {
|
switch item.sourceEdition {
|
||||||
case .world:
|
case .bedrock:
|
||||||
let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
|
switch item.contentType {
|
||||||
self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path)
|
case .world:
|
||||||
? "LevelDB world storage"
|
let levelDBURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
|
||||||
: "Flat-file world storage"
|
self.storageFormatLabel = fileManager.fileExists(atPath: levelDBURL.path)
|
||||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
? "LevelDB world storage"
|
||||||
self.storageFormatLabel = "Manifest-based package"
|
: "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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,49 @@ enum BedrockContentScanner {
|
|||||||
await packReferenceIndexStore.reset(for: sourceRootURL)
|
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(
|
nonisolated static func discoverItems(
|
||||||
in searchRootURL: URL,
|
in searchRootURL: URL,
|
||||||
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
@ -583,6 +626,52 @@ private actor PackReferenceIndexStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum JavaContentScanner {
|
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(
|
nonisolated static func discoverItems(
|
||||||
in searchRootURL: URL,
|
in searchRootURL: URL,
|
||||||
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
@ -603,14 +692,50 @@ enum JavaContentScanner {
|
|||||||
discoveredItems.append(contentsOf: resourcePackItems)
|
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.sort(by: WorldScanner.sortItems)
|
||||||
discoveredItems.forEach(onDiscovered)
|
discoveredItems.forEach(onDiscovered)
|
||||||
return discoveredItems
|
return discoveredItems
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
|
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
||||||
var enrichedItem = item
|
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.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
||||||
enrichedItem.metadataLoaded = true
|
enrichedItem.metadataLoaded = true
|
||||||
enrichedItem.previewLoaded = true
|
enrichedItem.previewLoaded = true
|
||||||
@ -620,7 +745,7 @@ enum JavaContentScanner {
|
|||||||
|
|
||||||
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
||||||
var sizedItem = item
|
var sizedItem = item
|
||||||
sizedItem.sizeBytes = WorldScanner.folderSize(at: item.folderURL, fileManager: .default)
|
sizedItem.sizeBytes = contentSize(at: item.folderURL, fileManager: .default)
|
||||||
sizedItem.sizeLoaded = true
|
sizedItem.sizeLoaded = true
|
||||||
return sizedItem
|
return sizedItem
|
||||||
}
|
}
|
||||||
@ -629,7 +754,10 @@ enum JavaContentScanner {
|
|||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let candidateRoots = [
|
let candidateRoots = [
|
||||||
existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager),
|
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
|
return candidateRoots.compactMap { collectionURL in
|
||||||
@ -663,22 +791,53 @@ enum JavaContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
||||||
let directories = try WorldScanner.immediateChildDirectories(of: resourcePacksURL, fileManager: fileManager)
|
try discoverJavaPackages(
|
||||||
return directories.compactMap { packURL in
|
in: resourcePacksURL,
|
||||||
guard fileManager.fileExists(atPath: packURL.appendingPathComponent("pack.mcmeta").path) else {
|
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 nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return MinecraftContentItem(
|
return javaContentItem(
|
||||||
folderURL: packURL,
|
url: childURL,
|
||||||
folderName: packURL.lastPathComponent,
|
contentKind: contentKind,
|
||||||
contentType: .resourcePack,
|
platformType: platformType,
|
||||||
sourceEdition: .java,
|
collectionRootURL: collectionURL
|
||||||
contentKind: .resourcePack,
|
|
||||||
platformType: .java(.resourcePack),
|
|
||||||
collectionRootURL: resourcePacksURL,
|
|
||||||
capabilities: .java(contentType: .resourcePack),
|
|
||||||
platformMetadata: .java(JavaContentMetadata())
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -692,6 +851,86 @@ enum JavaContentScanner {
|
|||||||
return directoryURL
|
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(
|
nonisolated private static func collectionSnapshot(
|
||||||
for collectionURL: URL,
|
for collectionURL: URL,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
@ -702,35 +941,41 @@ enum JavaContentScanner {
|
|||||||
|
|
||||||
let children = (try? fileManager.contentsOfDirectory(
|
let children = (try? fileManager.contentsOfDirectory(
|
||||||
at: collectionURL,
|
at: collectionURL,
|
||||||
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
|
||||||
options: [.skipsHiddenFiles]
|
options: [.skipsHiddenFiles]
|
||||||
)) ?? []
|
)) ?? []
|
||||||
let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in
|
let childSnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?, size: Int?)? in
|
||||||
guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
|
let values = try? childURL.resourceValues(forKeys: [
|
||||||
|
.isDirectoryKey,
|
||||||
|
.isRegularFileKey,
|
||||||
|
.contentModificationDateKey,
|
||||||
|
.fileSizeKey
|
||||||
|
])
|
||||||
|
guard values?.isDirectory == true || values?.isRegularFile == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
return (childURL.lastPathComponent, values?.contentModificationDate, values?.fileSize)
|
||||||
return (childURL.lastPathComponent, modifiedDate)
|
|
||||||
}.sorted {
|
}.sorted {
|
||||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||||
}
|
}
|
||||||
|
|
||||||
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
let childFingerprint = childDirectorySnapshots.map { child in
|
let childFingerprint = childSnapshots.map { child in
|
||||||
[
|
[
|
||||||
child.name,
|
child.name,
|
||||||
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil"
|
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
|
child.size.map(String.init) ?? "nil"
|
||||||
].joined(separator: "@")
|
].joined(separator: "@")
|
||||||
}.joined(separator: "|")
|
}.joined(separator: "|")
|
||||||
|
|
||||||
return CollectionSnapshot(
|
return CollectionSnapshot(
|
||||||
folderName: collectionURL.lastPathComponent,
|
folderName: collectionURL.lastPathComponent,
|
||||||
modifiedDate: modifiedDate,
|
modifiedDate: modifiedDate,
|
||||||
childDirectoryCount: childDirectorySnapshots.count,
|
childDirectoryCount: childSnapshots.count,
|
||||||
fingerprint: [
|
fingerprint: [
|
||||||
collectionURL.lastPathComponent,
|
collectionURL.lastPathComponent,
|
||||||
String(childDirectorySnapshots.count),
|
String(childSnapshots.count),
|
||||||
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
childFingerprint
|
childFingerprint
|
||||||
].joined(separator: "::")
|
].joined(separator: "::")
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -137,32 +137,53 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSource(at url: URL) -> URL {
|
func addSource(at url: URL) async -> URL {
|
||||||
let normalizedURL = url.standardizedFileURL
|
let selectedURL = url.standardizedFileURL
|
||||||
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
|
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 }) {
|
if sources.contains(where: { $0.id == normalizedURL }) {
|
||||||
updateSource(normalizedURL) { source in
|
updateSource(normalizedURL) { source in
|
||||||
if source.bookmarkData == nil {
|
if source.bookmarkData == nil {
|
||||||
source.bookmarkData = bookmarkData
|
source.bookmarkData = bookmarkData
|
||||||
}
|
}
|
||||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
source.accessDescriptor = SourceAccessDescriptor(
|
||||||
source.providerID = source.accessDescriptor.accessorIdentifier
|
accessorIdentifier: providerID,
|
||||||
|
kind: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
source.providerID = providerID
|
||||||
|
source.edition = edition
|
||||||
source.capabilities = source.origin.defaultCapabilities
|
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)
|
startScan(for: normalizedURL, mode: .fullScan)
|
||||||
return normalizedURL
|
return normalizedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = MinecraftSource(
|
var source = MinecraftSource(
|
||||||
folderURL: normalizedURL,
|
folderURL: normalizedURL,
|
||||||
bookmarkData: bookmarkData,
|
bookmarkData: bookmarkData,
|
||||||
accessDescriptor: SourceAccessDescriptor(
|
accessDescriptor: SourceAccessDescriptor(
|
||||||
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier,
|
accessorIdentifier: providerID,
|
||||||
kind: .localFolder,
|
kind: .localFolder,
|
||||||
refreshStrategy: .eagerFullScan
|
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)
|
return addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +194,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
existingSource.origin = source.origin
|
existingSource.origin = source.origin
|
||||||
existingSource.accessDescriptor = source.accessDescriptor
|
existingSource.accessDescriptor = source.accessDescriptor
|
||||||
existingSource.providerID = source.accessDescriptor.accessorIdentifier
|
existingSource.providerID = source.accessDescriptor.accessorIdentifier
|
||||||
existingSource.edition = source.origin.defaultEdition
|
existingSource.edition = source.edition
|
||||||
existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName)
|
existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName)
|
||||||
existingSource.availability = source.availability
|
existingSource.availability = source.availability
|
||||||
existingSource.capabilities = source.capabilities
|
existingSource.capabilities = source.capabilities
|
||||||
@ -188,7 +209,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
var resolvedSource = source
|
var resolvedSource = source
|
||||||
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
||||||
resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier
|
resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier
|
||||||
resolvedSource.edition = resolvedSource.origin.defaultEdition
|
resolvedSource.edition = source.edition
|
||||||
resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName)
|
resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName)
|
||||||
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
|
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
|
||||||
sources.append(resolvedSource)
|
sources.append(resolvedSource)
|
||||||
@ -441,8 +462,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
|
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
func currentCollectionSnapshots(for sourceURL: URL, edition: MinecraftEdition) -> [CollectionSnapshot] {
|
||||||
WorldScanner.collectionSnapshots(in: sourceURL)
|
switch edition {
|
||||||
|
case .bedrock:
|
||||||
|
return WorldScanner.collectionSnapshots(in: sourceURL)
|
||||||
|
case .java:
|
||||||
|
return JavaContentScanner.collectionSnapshots(in: sourceURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ protocol LocalSourceRuntimeHosting: AnyObject {
|
|||||||
func source(withID sourceID: URL) -> MinecraftSource?
|
func source(withID sourceID: URL) -> MinecraftSource?
|
||||||
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool)
|
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool)
|
||||||
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
|
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 {
|
enum LocalSourceRuntime {
|
||||||
@ -86,7 +86,7 @@ enum LocalSourceRuntime {
|
|||||||
|
|
||||||
if SourceRestoration.needsReconcile(
|
if SourceRestoration.needsReconcile(
|
||||||
refreshedSource,
|
refreshedSource,
|
||||||
currentCollectionSnapshots: host.currentCollectionSnapshots(for:)
|
currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:)
|
||||||
) {
|
) {
|
||||||
host.queueAutomaticSync(
|
host.queueAutomaticSync(
|
||||||
for: sourceID,
|
for: sourceID,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ protocol SourcePersistenceHosting: AnyObject {
|
|||||||
func refreshConnectedDevices() async
|
func refreshConnectedDevices() async
|
||||||
func refreshLocalSources() async
|
func refreshLocalSources() async
|
||||||
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
|
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
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ enum SourcePersistenceCoordinator {
|
|||||||
if let refreshReason = SourceRestoration.startupRefreshReason(
|
if let refreshReason = SourceRestoration.startupRefreshReason(
|
||||||
for: source,
|
for: source,
|
||||||
persistedRecord: persistedRecordsByID[source.id],
|
persistedRecord: persistedRecordsByID[source.id],
|
||||||
currentCollectionSnapshots: host.currentCollectionSnapshots(for:)
|
currentCollectionSnapshots: host.currentCollectionSnapshots(for:edition:)
|
||||||
) {
|
) {
|
||||||
host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil)
|
host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ enum SourceRestoration {
|
|||||||
accessDescriptor: record.accessDescriptor,
|
accessDescriptor: record.accessDescriptor,
|
||||||
availability: record.availability
|
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 {
|
if case .connectedDevice(let device, let container) = source.origin {
|
||||||
var repairedDevice = device
|
var repairedDevice = device
|
||||||
@ -104,7 +106,7 @@ enum SourceRestoration {
|
|||||||
static func startupRefreshReason(
|
static func startupRefreshReason(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
persistedRecord: PersistedSourceRecord?,
|
persistedRecord: PersistedSourceRecord?,
|
||||||
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
|
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
|
||||||
) -> String? {
|
) -> String? {
|
||||||
guard source.availability == .available else {
|
guard source.availability == .available else {
|
||||||
return nil
|
return nil
|
||||||
@ -133,7 +135,7 @@ enum SourceRestoration {
|
|||||||
|
|
||||||
static func needsReconcile(
|
static func needsReconcile(
|
||||||
_ source: MinecraftSource,
|
_ source: MinecraftSource,
|
||||||
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
|
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots)
|
reconcileIsNeeded(source, currentCollectionSnapshots: currentCollectionSnapshots)
|
||||||
}
|
}
|
||||||
@ -152,7 +154,7 @@ enum SourceRestoration {
|
|||||||
|
|
||||||
private static func needsRescan(
|
private static func needsRescan(
|
||||||
_ record: PersistedSourceRecord,
|
_ record: PersistedSourceRecord,
|
||||||
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
|
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard record.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
guard record.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
||||||
return record.rawItems.isEmpty
|
return record.rawItems.isEmpty
|
||||||
@ -167,15 +169,16 @@ enum SourceRestoration {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let edition = edition(for: record.accessDescriptor, origin: record.origin)
|
||||||
return collectionsDiffer(
|
return collectionsDiffer(
|
||||||
currentCollectionSnapshots(sourceURL),
|
currentCollectionSnapshots(sourceURL, edition),
|
||||||
persistedCollections: snapshot.collectionSnapshots
|
persistedCollections: snapshot.collectionSnapshots
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func reconcileIsNeeded(
|
private static func reconcileIsNeeded(
|
||||||
_ source: MinecraftSource,
|
_ source: MinecraftSource,
|
||||||
currentCollectionSnapshots: (URL) -> [CollectionSnapshot]
|
currentCollectionSnapshots: (URL, MinecraftEdition) -> [CollectionSnapshot]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard source.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
guard source.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
||||||
return source.rawItems.isEmpty
|
return source.rawItems.isEmpty
|
||||||
@ -191,11 +194,22 @@ enum SourceRestoration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collectionsDiffer(
|
return collectionsDiffer(
|
||||||
currentCollectionSnapshots(sourceURL),
|
currentCollectionSnapshots(sourceURL, source.edition),
|
||||||
persistedCollections: snapshot.collectionSnapshots
|
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(
|
private static func collectionsDiffer(
|
||||||
_ currentCollections: [CollectionSnapshot],
|
_ currentCollections: [CollectionSnapshot],
|
||||||
persistedCollections: [CollectionSnapshot]
|
persistedCollections: [CollectionSnapshot]
|
||||||
|
|||||||
@ -10,6 +10,7 @@ enum SourceDiscoveryMode: Sendable {
|
|||||||
|
|
||||||
protocol SourceAccessMethod: Sendable {
|
protocol SourceAccessMethod: Sendable {
|
||||||
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult?
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||||
@ -38,6 +39,11 @@ extension SourceAccessMethod {
|
|||||||
String(reflecting: Self.self)
|
String(reflecting: Self.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
|
_ = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
SourceAccessDescriptor(
|
SourceAccessDescriptor(
|
||||||
accessorIdentifier: accessorIdentifier,
|
accessorIdentifier: accessorIdentifier,
|
||||||
@ -221,6 +227,30 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).")
|
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(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
mode: SourceDiscoveryMode,
|
mode: SourceDiscoveryMode,
|
||||||
|
|||||||
@ -10,6 +10,10 @@ struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated init() {}
|
nonisolated init() {}
|
||||||
|
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
|
BedrockContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
_ = source
|
_ = source
|
||||||
return SourceAccessDescriptor(
|
return SourceAccessDescriptor(
|
||||||
@ -214,6 +218,10 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated init() {}
|
nonisolated init() {}
|
||||||
|
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
|
JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
_ = source
|
_ = source
|
||||||
return SourceAccessDescriptor(
|
return SourceAccessDescriptor(
|
||||||
@ -226,8 +234,15 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
let candidateURL: URL
|
let candidateURL: URL
|
||||||
let mode: SourceAccessMode
|
let mode: SourceAccessMode
|
||||||
if case .javaLocalFolder(let bookmarkData) = source.origin,
|
let bookmarkData: Data?
|
||||||
let bookmarkData {
|
switch source.origin {
|
||||||
|
case .javaLocalFolder(let data), .localFolder(let data):
|
||||||
|
bookmarkData = data
|
||||||
|
case .connectedDevice:
|
||||||
|
bookmarkData = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bookmarkData {
|
||||||
mode = .securityScopedLocalFolder
|
mode = .securityScopedLocalFolder
|
||||||
var isStale = false
|
var isStale = false
|
||||||
if let resolvedURL = try? URL(
|
if let resolvedURL = try? URL(
|
||||||
@ -267,7 +282,11 @@ struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
_ = mode
|
_ = 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(
|
throw SourceAccessError.accessFailed(
|
||||||
reason: "No Java local-folder access method is configured for this source type."
|
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 {
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
_ = source
|
_ = source
|
||||||
return JavaContentScanner.enrich(item: item)
|
return await JavaContentScanner.enrich(item: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
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] {
|
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
|
||||||
_ = source
|
_ = source
|
||||||
|
let values = try? item.folderURL.resourceValues(forKeys: [.isDirectoryKey])
|
||||||
|
guard values?.isDirectory == true else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return try await BedrockLocalFolderSourceAccess().listItemContents(for: item, in: source)
|
return try await BedrockLocalFolderSourceAccess().listItemContents(for: item, in: source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,14 +197,26 @@ struct SourceDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var contentRows: [(String, String)] {
|
private var contentRows: [(String, String)] {
|
||||||
[
|
var rows = [("Total Items", source.items.count.formatted(.number))]
|
||||||
("Total Items", source.items.count.formatted(.number)),
|
let orderedKinds: [(MinecraftContentKind, String)] = [
|
||||||
("Worlds", itemCount(for: .world).formatted(.number)),
|
(.world, "Worlds"),
|
||||||
("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)),
|
(.behaviorPack, "Behavior Packs"),
|
||||||
("Resource Packs", itemCount(for: .resourcePack).formatted(.number)),
|
(.resourcePack, "Resource Packs"),
|
||||||
("Skin Packs", itemCount(for: .skinPack).formatted(.number)),
|
(.dataPack, "Data Packs"),
|
||||||
("World Templates", itemCount(for: .worldTemplate).formatted(.number))
|
(.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)] {
|
private var locationRows: [(String, String)] {
|
||||||
@ -485,6 +497,14 @@ struct SourceDetailView: View {
|
|||||||
source.items.filter { $0.contentType == type }.count
|
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
|
@ViewBuilder
|
||||||
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@ -574,8 +574,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for url in panel.urls {
|
for url in panel.urls {
|
||||||
let sourceID = library.addSource(at: url)
|
Task { @MainActor in
|
||||||
selectSourceIfNeeded(sourceID)
|
let sourceID = await library.addSource(at: url)
|
||||||
|
selectSourceIfNeeded(sourceID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,7 +598,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let sourceID = library.addSource(at: url)
|
let sourceID = await library.addSource(at: url)
|
||||||
selectSourceIfNeeded(sourceID)
|
selectSourceIfNeeded(sourceID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,8 +148,12 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
@Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws {
|
@Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
let worldURL = rootURL.appendingPathComponent("saves/JavaWorld", isDirectory: true)
|
let instanceURL = rootURL.appendingPathComponent("Better MC [NEOFORGE] BMC5", isDirectory: true)
|
||||||
let packURL = rootURL.appendingPathComponent("resourcepacks/JavaPack", 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) }
|
defer { try? fileManager.removeItem(at: rootURL) }
|
||||||
|
|
||||||
try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true)
|
||||||
@ -165,17 +169,35 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
atomically: true,
|
atomically: true,
|
||||||
encoding: .utf8
|
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(
|
let access = SourceAccessCoordinator(
|
||||||
accessMethods: [
|
accessMethods: [
|
||||||
LocalFolderSourceAccess(),
|
LocalFolderSourceAccess(),
|
||||||
JavaLocalFolderSourceAccess()
|
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] = []
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
|
||||||
for try await event in access.scanEvents(for: source, mode: .fullScan) {
|
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))
|
enrichedItems.append(await access.enrich(item, for: source))
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(discoveredItems.count == 2)
|
#expect(discoveredItems.count == 5)
|
||||||
#expect(discoveredItems.allSatisfy { $0.sourceEdition == .java })
|
#expect(discoveredItems.allSatisfy { $0.sourceEdition == .java })
|
||||||
#expect(discoveredItems.contains { $0.platformType == .java(.world) && $0.capabilities.portablePackageExtension == "zip" })
|
#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(.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" })
|
#expect(enrichedItems.contains { $0.displayName == "Displayed Java World" })
|
||||||
|
|
||||||
var indexedSource = source
|
var indexedSource = source
|
||||||
indexedSource.rawItems = enrichedItems
|
indexedSource.rawItems = enrichedItems
|
||||||
let index = SourceContentIndexer.buildIndex(for: indexedSource)
|
let index = SourceContentIndexer.buildIndex(for: indexedSource)
|
||||||
#expect(index.displayItemCountsByKind[.world] == 1)
|
#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
|
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("saves"))
|
||||||
#expect(snapshot.collectionSnapshots.map(\.folderName).contains("resourcepacks"))
|
#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 {
|
@Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws {
|
||||||
@ -1120,6 +1275,106 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
#expect(restored[0].lastScanDate == legacyRecord.lastScanDate)
|
#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 {
|
@Test func connectedDeviceSourceFactoryCreatesStableSyntheticIdentifier() async throws {
|
||||||
let device = ConnectedDevice(
|
let device = ConnectedDevice(
|
||||||
udid: "00008110-001234560E90001E",
|
udid: "00008110-001234560E90001E",
|
||||||
|
|||||||
@ -51,6 +51,36 @@ UI
|
|||||||
|
|
||||||
## Core Concepts
|
## 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
|
### Provider
|
||||||
|
|
||||||
A provider is the unit that knows a platform and access method. A provider can
|
A provider is the unit that knows a platform and access method. A provider can
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user