Compare commits
13 Commits
main
...
jmbwell/pr
| Author | SHA1 | Date | |
|---|---|---|---|
| f64d3de90d | |||
| da53ee4e9b | |||
| 0e52db80df | |||
| f5dfec00a3 | |||
| 9ec6b905bc | |||
| ffb6e497ec | |||
| ca21654b44 | |||
| 6e728724bb | |||
| ba6edf6cc4 | |||
| bd177832c0 | |||
| bb4ef36f44 | |||
| 2639bca571 | |||
| 14d9048b57 |
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/
|
||||||
|
|||||||
12
README.md
12
README.md
@ -17,8 +17,6 @@ This thing is called "World Manager," and it happens to pertain to someone else'
|
|||||||
- Detect worlds, resource packs, behavior packs, and world templates.
|
- Detect worlds, resource packs, behavior packs, and world templates.
|
||||||
- Inspect package metadata, pack relationships, icons, versions, UUIDs, and basic world facts.
|
- Inspect package metadata, pack relationships, icons, versions, UUIDs, and basic world facts.
|
||||||
- Export worlds and packs as portable `.mcworld`, `.mcpack`, `.mctemplate`, and `.mcaddon` packages, or share them via Messages, AirDrop, etc. via the "Share" sheet.
|
- Export worlds and packs as portable `.mcworld`, `.mcpack`, `.mctemplate`, and `.mcaddon` packages, or share them via Messages, AirDrop, etc. via the "Share" sheet.
|
||||||
- Import `.mcworld`, `.mcpack`, `.mctemplate`, and nested-package `.mcaddon` files into writable folder and connected-device sources.
|
|
||||||
- Copy an item between sources by dragging it onto the destination source in the sidebar.
|
|
||||||
- Preview and thumbnail supported Minecraft package files with Quick Look extensions.
|
- Preview and thumbnail supported Minecraft package files with Quick Look extensions.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@ -45,14 +43,6 @@ From an item's detail view, you can export it to a Minecraft package file on you
|
|||||||
|
|
||||||
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
|
If you share to another device with AirDrop, Messages, or a similar route, opening the received file on that device should launch Minecraft and begin Minecraft's normal import flow.
|
||||||
|
|
||||||
### Importing and Copying
|
|
||||||
|
|
||||||
Select a source and use `Import...` on its overview, or drop a supported Minecraft package onto the source row in the sidebar. World Manager inspects the package and places each item in the collection appropriate to its content type.
|
|
||||||
|
|
||||||
You can also drag an existing world or pack from the item list onto another source. The app exports a temporary portable representation, validates it through the same import path, and installs it in the destination.
|
|
||||||
|
|
||||||
Imports are additive. Existing worlds are never overwritten. A second world import receives a unique directory name. Packs with a UUID already present in the destination are rejected instead of being silently replaced.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 26.2 or newer, based on the current Xcode project deployment target. Could work with older macOS. I don't have any so I don't know.
|
- macOS 26.2 or newer, based on the current Xcode project deployment target. Could work with older macOS. I don't have any so I don't know.
|
||||||
@ -107,7 +97,7 @@ See [docs/ios-device-access.md](docs/ios-device-access.md) for the current devic
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. Connected-device installation writes through private Apple MobileDevice interfaces and should be treated as best-effort. The initial implementation installs only into new directories and does not replace or remove existing content.
|
This is pre-release software. Make sure Minecraft worlds and packs are being backed up by other means before using this app. The project is currently focused on read-only library access and export, and connected-device discovery, so it shouldn't be doing anything that can break your existing libraries.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,6 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -73,7 +73,6 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|||||||
@ -152,7 +152,7 @@ nonisolated struct CollectionSnapshot: Identifiable, Hashable, Sendable, Codable
|
|||||||
let childDirectoryCount: Int
|
let childDirectoryCount: Int
|
||||||
let fingerprint: String
|
let fingerprint: String
|
||||||
|
|
||||||
var id: String { folderName }
|
var id: String { "\(folderName)::\(fingerprint)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {
|
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {
|
||||||
|
|||||||
@ -3,6 +3,47 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
typealias PlatformProviderID = String
|
||||||
|
|
||||||
|
nonisolated enum MinecraftEdition: String, CaseIterable, Hashable, Sendable, Codable {
|
||||||
|
case bedrock
|
||||||
|
case java
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum MinecraftContentKind: String, CaseIterable, Hashable, Sendable, Codable {
|
||||||
|
case world
|
||||||
|
case behaviorPack
|
||||||
|
case resourcePack
|
||||||
|
case dataPack
|
||||||
|
case skinPack
|
||||||
|
case worldTemplate
|
||||||
|
case shaderPack
|
||||||
|
case mod
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum JavaContentType: String, CaseIterable, Hashable, Sendable, Codable {
|
||||||
|
case world = "Java World"
|
||||||
|
case resourcePack = "Java Resource Pack"
|
||||||
|
case dataPack = "Java Data Pack"
|
||||||
|
case shaderPack = "Java Shader Pack"
|
||||||
|
case mod = "Java Mod"
|
||||||
|
|
||||||
|
nonisolated var kind: MinecraftContentKind {
|
||||||
|
switch self {
|
||||||
|
case .world:
|
||||||
|
return .world
|
||||||
|
case .resourcePack:
|
||||||
|
return .resourcePack
|
||||||
|
case .dataPack:
|
||||||
|
return .dataPack
|
||||||
|
case .shaderPack:
|
||||||
|
return .shaderPack
|
||||||
|
case .mod:
|
||||||
|
return .mod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable {
|
nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable, Codable {
|
||||||
case world = "World"
|
case world = "World"
|
||||||
case behaviorPack = "Behavior Pack"
|
case behaviorPack = "Behavior Pack"
|
||||||
@ -25,6 +66,21 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated var kind: MinecraftContentKind {
|
||||||
|
switch self {
|
||||||
|
case .world:
|
||||||
|
return .world
|
||||||
|
case .behaviorPack:
|
||||||
|
return .behaviorPack
|
||||||
|
case .resourcePack:
|
||||||
|
return .resourcePack
|
||||||
|
case .skinPack:
|
||||||
|
return .skinPack
|
||||||
|
case .worldTemplate:
|
||||||
|
return .worldTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated var archiveExtension: String {
|
nonisolated var archiveExtension: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .world:
|
case .world:
|
||||||
@ -52,6 +108,71 @@ nonisolated enum MinecraftContentType: String, CaseIterable, Hashable, Sendable,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated enum MinecraftPlatformContentType: Hashable, Sendable, Codable {
|
||||||
|
case bedrock(MinecraftContentType)
|
||||||
|
case java(JavaContentType)
|
||||||
|
|
||||||
|
nonisolated var edition: MinecraftEdition {
|
||||||
|
switch self {
|
||||||
|
case .bedrock:
|
||||||
|
return .bedrock
|
||||||
|
case .java:
|
||||||
|
return .java
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var kind: MinecraftContentKind {
|
||||||
|
switch self {
|
||||||
|
case .bedrock(let contentType):
|
||||||
|
return contentType.kind
|
||||||
|
case .java(let contentType):
|
||||||
|
return contentType.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .bedrock(let contentType):
|
||||||
|
return contentType.rawValue
|
||||||
|
case .java(let contentType):
|
||||||
|
return contentType.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct ContentItemCapabilities: Hashable, Sendable, Codable {
|
||||||
|
var canRevealNativeContent: Bool
|
||||||
|
var canExportPortablePackage: Bool
|
||||||
|
var canShare: Bool
|
||||||
|
var portablePackageExtension: String?
|
||||||
|
|
||||||
|
nonisolated static func bedrock(contentType: MinecraftContentType) -> ContentItemCapabilities {
|
||||||
|
ContentItemCapabilities(
|
||||||
|
canRevealNativeContent: true,
|
||||||
|
canExportPortablePackage: true,
|
||||||
|
canShare: true,
|
||||||
|
portablePackageExtension: contentType.archiveExtension
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func java(contentType: JavaContentType) -> ContentItemCapabilities {
|
||||||
|
let extensionName: String?
|
||||||
|
switch contentType {
|
||||||
|
case .world, .resourcePack, .dataPack, .shaderPack:
|
||||||
|
extensionName = "zip"
|
||||||
|
case .mod:
|
||||||
|
extensionName = "jar"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentItemCapabilities(
|
||||||
|
canRevealNativeContent: true,
|
||||||
|
canExportPortablePackage: true,
|
||||||
|
canShare: true,
|
||||||
|
portablePackageExtension: extensionName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated enum PackSource: String, Hashable, Sendable, Codable {
|
nonisolated enum PackSource: String, Hashable, Sendable, Codable {
|
||||||
case referencedByWorld
|
case referencedByWorld
|
||||||
case embeddedInWorld
|
case embeddedInWorld
|
||||||
@ -113,11 +234,119 @@ nonisolated struct PackMetadataDetails: Hashable, Sendable, Codable {
|
|||||||
var minimumEngineVersion: String?
|
var minimumEngineVersion: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated enum PlatformContentMetadata: Hashable, Sendable, Codable {
|
||||||
|
case bedrock(BedrockContentMetadata)
|
||||||
|
case java(JavaContentMetadata)
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct BedrockContentMetadata: Hashable, Sendable, Codable {
|
||||||
|
var world: WorldMetadata?
|
||||||
|
var packUUID: String?
|
||||||
|
var packVersion: String?
|
||||||
|
var packDetails: PackMetadataDetails?
|
||||||
|
var packReferences: [ContentPackReference]
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
world: WorldMetadata? = nil,
|
||||||
|
packUUID: String? = nil,
|
||||||
|
packVersion: String? = nil,
|
||||||
|
packDetails: PackMetadataDetails? = nil,
|
||||||
|
packReferences: [ContentPackReference] = []
|
||||||
|
) {
|
||||||
|
self.world = world
|
||||||
|
self.packUUID = packUUID?.lowercased()
|
||||||
|
self.packVersion = packVersion
|
||||||
|
self.packDetails = packDetails
|
||||||
|
self.packReferences = packReferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable {
|
||||||
|
var world: JavaWorldMetadata?
|
||||||
|
var pack: JavaPackMetadata?
|
||||||
|
var mod: JavaModMetadata?
|
||||||
|
var dataPacks: [JavaPackReference]
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
world: JavaWorldMetadata? = nil,
|
||||||
|
pack: JavaPackMetadata? = nil,
|
||||||
|
mod: JavaModMetadata? = nil,
|
||||||
|
dataPacks: [JavaPackReference] = []
|
||||||
|
) {
|
||||||
|
self.world = world
|
||||||
|
self.pack = pack
|
||||||
|
self.mod = mod
|
||||||
|
self.dataPacks = dataPacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct JavaWorldMetadata: Hashable, Sendable, Codable {
|
||||||
|
var dataVersion: String?
|
||||||
|
var gameMode: String?
|
||||||
|
var difficulty: String?
|
||||||
|
var seed: String?
|
||||||
|
var lastPlayedDate: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable {
|
||||||
|
var packFormat: Int?
|
||||||
|
var supportedFormats: String?
|
||||||
|
var description: String?
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
packFormat: Int? = nil,
|
||||||
|
supportedFormats: String? = nil,
|
||||||
|
description: String? = nil
|
||||||
|
) {
|
||||||
|
self.packFormat = packFormat
|
||||||
|
self.supportedFormats = supportedFormats
|
||||||
|
self.description = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct JavaModMetadata: Hashable, Sendable, Codable {
|
||||||
|
var modID: String?
|
||||||
|
var version: String?
|
||||||
|
var description: String?
|
||||||
|
var authors: [String]
|
||||||
|
var license: String?
|
||||||
|
var environment: String?
|
||||||
|
var minecraftVersionRequirement: String?
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
modID: String? = nil,
|
||||||
|
version: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
authors: [String] = [],
|
||||||
|
license: String? = nil,
|
||||||
|
environment: String? = nil,
|
||||||
|
minecraftVersionRequirement: String? = nil
|
||||||
|
) {
|
||||||
|
self.modID = modID
|
||||||
|
self.version = version
|
||||||
|
self.description = description
|
||||||
|
self.authors = authors
|
||||||
|
self.license = license
|
||||||
|
self.environment = environment
|
||||||
|
self.minecraftVersionRequirement = minecraftVersionRequirement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable {
|
||||||
|
let id: String
|
||||||
|
var name: String
|
||||||
|
var pathHint: String?
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||||
let id: URL
|
let id: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
let folderName: String
|
let folderName: String
|
||||||
let contentType: MinecraftContentType
|
let contentType: MinecraftContentType
|
||||||
|
let sourceEdition: MinecraftEdition
|
||||||
|
let contentKind: MinecraftContentKind
|
||||||
|
let platformType: MinecraftPlatformContentType
|
||||||
let collectionRootURL: URL
|
let collectionRootURL: URL
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var iconURL: URL?
|
var iconURL: URL?
|
||||||
@ -125,11 +354,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
var lastPlayedDate: Date?
|
var lastPlayedDate: Date?
|
||||||
var modifiedDate: Date?
|
var modifiedDate: Date?
|
||||||
var sizeBytes: Int64?
|
var sizeBytes: Int64?
|
||||||
var packUUID: String?
|
var capabilities: ContentItemCapabilities
|
||||||
var packVersion: String?
|
var platformMetadata: PlatformContentMetadata
|
||||||
var packMetadataDetails: PackMetadataDetails?
|
var packUUID: String? {
|
||||||
var packReferences: [ContentPackReference]
|
didSet { syncBedrockMetadataFromCompatibilityFields() }
|
||||||
var worldMetadata: WorldMetadata?
|
}
|
||||||
|
var packVersion: String? {
|
||||||
|
didSet { syncBedrockMetadataFromCompatibilityFields() }
|
||||||
|
}
|
||||||
|
var packMetadataDetails: PackMetadataDetails? {
|
||||||
|
didSet { syncBedrockMetadataFromCompatibilityFields() }
|
||||||
|
}
|
||||||
|
var packReferences: [ContentPackReference] {
|
||||||
|
didSet { syncBedrockMetadataFromCompatibilityFields() }
|
||||||
|
}
|
||||||
|
var worldMetadata: WorldMetadata? {
|
||||||
|
didSet { syncBedrockMetadataFromCompatibilityFields() }
|
||||||
|
}
|
||||||
var metadataLoaded: Bool
|
var metadataLoaded: Bool
|
||||||
var previewLoaded: Bool
|
var previewLoaded: Bool
|
||||||
var sizeLoaded: Bool
|
var sizeLoaded: Bool
|
||||||
@ -138,6 +379,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
folderURL: URL,
|
folderURL: URL,
|
||||||
folderName: String,
|
folderName: String,
|
||||||
contentType: MinecraftContentType,
|
contentType: MinecraftContentType,
|
||||||
|
sourceEdition: MinecraftEdition? = nil,
|
||||||
|
contentKind: MinecraftContentKind? = nil,
|
||||||
|
platformType: MinecraftPlatformContentType? = nil,
|
||||||
collectionRootURL: URL,
|
collectionRootURL: URL,
|
||||||
displayName: String? = nil,
|
displayName: String? = nil,
|
||||||
iconURL: URL? = nil,
|
iconURL: URL? = nil,
|
||||||
@ -145,6 +389,8 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
lastPlayedDate: Date? = nil,
|
lastPlayedDate: Date? = nil,
|
||||||
modifiedDate: Date? = nil,
|
modifiedDate: Date? = nil,
|
||||||
sizeBytes: Int64? = nil,
|
sizeBytes: Int64? = nil,
|
||||||
|
capabilities: ContentItemCapabilities? = nil,
|
||||||
|
platformMetadata: PlatformContentMetadata? = nil,
|
||||||
packUUID: String? = nil,
|
packUUID: String? = nil,
|
||||||
packVersion: String? = nil,
|
packVersion: String? = nil,
|
||||||
packMetadataDetails: PackMetadataDetails? = nil,
|
packMetadataDetails: PackMetadataDetails? = nil,
|
||||||
@ -158,6 +404,9 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
self.folderURL = folderURL
|
self.folderURL = folderURL
|
||||||
self.folderName = folderName
|
self.folderName = folderName
|
||||||
self.contentType = contentType
|
self.contentType = contentType
|
||||||
|
self.sourceEdition = sourceEdition ?? .bedrock
|
||||||
|
self.contentKind = contentKind ?? contentType.kind
|
||||||
|
self.platformType = platformType ?? .bedrock(contentType)
|
||||||
self.collectionRootURL = collectionRootURL
|
self.collectionRootURL = collectionRootURL
|
||||||
self.displayName = displayName ?? folderName
|
self.displayName = displayName ?? folderName
|
||||||
self.iconURL = iconURL
|
self.iconURL = iconURL
|
||||||
@ -165,6 +414,16 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
self.lastPlayedDate = lastPlayedDate
|
self.lastPlayedDate = lastPlayedDate
|
||||||
self.modifiedDate = modifiedDate
|
self.modifiedDate = modifiedDate
|
||||||
self.sizeBytes = sizeBytes
|
self.sizeBytes = sizeBytes
|
||||||
|
self.capabilities = capabilities ?? .bedrock(contentType: contentType)
|
||||||
|
self.platformMetadata = platformMetadata ?? .bedrock(
|
||||||
|
BedrockContentMetadata(
|
||||||
|
world: worldMetadata,
|
||||||
|
packUUID: packUUID,
|
||||||
|
packVersion: packVersion,
|
||||||
|
packDetails: packMetadataDetails,
|
||||||
|
packReferences: packReferences
|
||||||
|
)
|
||||||
|
)
|
||||||
self.packUUID = packUUID?.lowercased()
|
self.packUUID = packUUID?.lowercased()
|
||||||
self.packVersion = packVersion
|
self.packVersion = packVersion
|
||||||
self.packMetadataDetails = packMetadataDetails
|
self.packMetadataDetails = packMetadataDetails
|
||||||
@ -175,6 +434,22 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
self.sizeLoaded = sizeLoaded
|
self.sizeLoaded = sizeLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated mutating private func syncBedrockMetadataFromCompatibilityFields() {
|
||||||
|
guard sourceEdition == .bedrock else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
platformMetadata = .bedrock(
|
||||||
|
BedrockContentMetadata(
|
||||||
|
world: worldMetadata,
|
||||||
|
packUUID: packUUID,
|
||||||
|
packVersion: packVersion,
|
||||||
|
packDetails: packMetadataDetails,
|
||||||
|
packReferences: packReferences
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated var folderID: String {
|
nonisolated var folderID: String {
|
||||||
folderName
|
folderName
|
||||||
}
|
}
|
||||||
@ -201,6 +476,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
|||||||
values.append(packMetadataDetails?.minimumEngineVersion ?? "")
|
values.append(packMetadataDetails?.minimumEngineVersion ?? "")
|
||||||
values.append(packReferences.map(\.name).joined(separator: " "))
|
values.append(packReferences.map(\.name).joined(separator: " "))
|
||||||
values.append(packReferences.compactMap(\.uuid).joined(separator: " "))
|
values.append(packReferences.compactMap(\.uuid).joined(separator: " "))
|
||||||
|
if case .java(let metadata) = platformMetadata {
|
||||||
|
values.append(metadata.world?.dataVersion ?? "")
|
||||||
|
values.append(metadata.world?.gameMode ?? "")
|
||||||
|
values.append(metadata.world?.difficulty ?? "")
|
||||||
|
values.append(metadata.world?.seed ?? "")
|
||||||
|
values.append(metadata.pack?.description ?? "")
|
||||||
|
values.append(metadata.pack?.packFormat.map(String.init) ?? "")
|
||||||
|
values.append(metadata.pack?.supportedFormats ?? "")
|
||||||
|
values.append(metadata.mod?.modID ?? "")
|
||||||
|
values.append(metadata.mod?.version ?? "")
|
||||||
|
values.append(metadata.mod?.description ?? "")
|
||||||
|
values.append(metadata.mod?.authors.joined(separator: " ") ?? "")
|
||||||
|
values.append(metadata.mod?.license ?? "")
|
||||||
|
values.append(metadata.mod?.environment ?? "")
|
||||||
|
values.append(metadata.mod?.minecraftVersionRequirement ?? "")
|
||||||
|
values.append(metadata.dataPacks.map(\.name).joined(separator: " "))
|
||||||
|
}
|
||||||
|
|
||||||
return values
|
return values
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
|||||||
@ -6,14 +6,18 @@ import Foundation
|
|||||||
nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||||
let id: URL
|
let id: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
|
var edition: MinecraftEdition
|
||||||
|
var providerID: PlatformProviderID
|
||||||
var origin: MinecraftSourceOrigin
|
var origin: MinecraftSourceOrigin
|
||||||
var accessDescriptor: SourceAccessDescriptor
|
var accessDescriptor: SourceAccessDescriptor
|
||||||
|
var accessStatus: SourceAccessStatus
|
||||||
var availability: SourceAvailability
|
var availability: SourceAvailability
|
||||||
var capabilities: SourceCapabilities
|
var capabilities: SourceCapabilities
|
||||||
var bookmarkData: Data?
|
var bookmarkData: Data?
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var displayItems: [MinecraftContentItem]
|
var displayItems: [MinecraftContentItem]
|
||||||
var displayItemCountsByType: [MinecraftContentType: Int]
|
var displayItemCountsByType: [MinecraftContentType: Int]
|
||||||
|
var displayItemCountsByKind: [MinecraftContentKind: Int]
|
||||||
var rawItems: [MinecraftContentItem]
|
var rawItems: [MinecraftContentItem]
|
||||||
var logicalPacks: [LogicalPack]
|
var logicalPacks: [LogicalPack]
|
||||||
var logicalWorlds: [LogicalWorld]
|
var logicalWorlds: [LogicalWorld]
|
||||||
@ -47,18 +51,22 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||||
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
||||||
self.folderURL = normalizedFolderURL
|
self.folderURL = normalizedFolderURL
|
||||||
|
self.edition = resolvedOrigin.defaultEdition
|
||||||
|
self.providerID = resolvedOrigin.defaultAccessorIdentifier
|
||||||
self.origin = resolvedOrigin
|
self.origin = resolvedOrigin
|
||||||
self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor(
|
self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor(
|
||||||
accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier,
|
accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier,
|
||||||
kind: resolvedOrigin.kind,
|
kind: resolvedOrigin.kind,
|
||||||
refreshStrategy: resolvedOrigin.defaultRefreshStrategy
|
refreshStrategy: resolvedOrigin.defaultRefreshStrategy
|
||||||
)
|
)
|
||||||
|
self.accessStatus = resolvedOrigin.defaultAccessStatus(displayName: normalizedFolderURL.lastPathComponent)
|
||||||
self.availability = availability
|
self.availability = availability
|
||||||
self.capabilities = resolvedOrigin.defaultCapabilities
|
self.capabilities = resolvedOrigin.defaultCapabilities
|
||||||
self.bookmarkData = bookmarkData
|
self.bookmarkData = bookmarkData
|
||||||
self.displayName = normalizedFolderURL.lastPathComponent
|
self.displayName = normalizedFolderURL.lastPathComponent
|
||||||
self.displayItems = []
|
self.displayItems = []
|
||||||
self.displayItemCountsByType = [:]
|
self.displayItemCountsByType = [:]
|
||||||
|
self.displayItemCountsByKind = [:]
|
||||||
self.rawItems = []
|
self.rawItems = []
|
||||||
self.logicalPacks = []
|
self.logicalPacks = []
|
||||||
self.logicalWorlds = []
|
self.logicalWorlds = []
|
||||||
@ -107,6 +115,8 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .sourceCandidate, .connectedDevice:
|
||||||
|
return []
|
||||||
case .source(let sourceID), .allContent(let sourceID):
|
case .source(let sourceID), .allContent(let sourceID):
|
||||||
guard sourceID == id else {
|
guard sourceID == id else {
|
||||||
return []
|
return []
|
||||||
@ -117,6 +127,11 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return items(for: contentType)
|
return items(for: contentType)
|
||||||
|
case .contentKind(let sourceID, let contentKind):
|
||||||
|
guard sourceID == id else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return displayItems.filter { $0.contentKind == contentKind }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,19 +7,16 @@ nonisolated struct SourceCapabilities: Hashable, Sendable, Codable {
|
|||||||
var canScan: Bool = true
|
var canScan: Bool = true
|
||||||
var canMaterializeItems: Bool = true
|
var canMaterializeItems: Bool = true
|
||||||
var canExportPortablePackages: Bool = true
|
var canExportPortablePackages: Bool = true
|
||||||
var canInstallItems: Bool = false
|
|
||||||
|
|
||||||
static let localFolder = SourceCapabilities(
|
static let localFolder = SourceCapabilities(
|
||||||
canScan: true,
|
canScan: true,
|
||||||
canMaterializeItems: true,
|
canMaterializeItems: true,
|
||||||
canExportPortablePackages: true,
|
canExportPortablePackages: true
|
||||||
canInstallItems: true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
static let connectedDevice = SourceCapabilities(
|
static let connectedDevice = SourceCapabilities(
|
||||||
canScan: true,
|
canScan: true,
|
||||||
canMaterializeItems: true,
|
canMaterializeItems: true,
|
||||||
canExportPortablePackages: true,
|
canExportPortablePackages: true
|
||||||
canInstallItems: true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,20 +45,32 @@ nonisolated enum DeviceContainerAccessMode: String, Hashable, Sendable, Codable
|
|||||||
|
|
||||||
nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
||||||
case localFolder(bookmarkData: Data?)
|
case localFolder(bookmarkData: Data?)
|
||||||
|
case javaLocalFolder(bookmarkData: Data?)
|
||||||
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
|
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
|
||||||
|
|
||||||
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
|
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
|
||||||
switch self {
|
switch self {
|
||||||
case .localFolder:
|
case .localFolder:
|
||||||
return LocalFolderSourceAccess().accessorIdentifier
|
return LocalFolderSourceAccess().accessorIdentifier
|
||||||
|
case .javaLocalFolder:
|
||||||
|
return JavaLocalFolderSourceAccess().accessorIdentifier
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return AppleMobileDeviceSourceAccess().accessorIdentifier
|
return AppleMobileDeviceSourceAccess().accessorIdentifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated var defaultEdition: MinecraftEdition {
|
||||||
|
switch self {
|
||||||
|
case .localFolder, .connectedDevice:
|
||||||
|
return .bedrock
|
||||||
|
case .javaLocalFolder:
|
||||||
|
return .java
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated var kind: MinecraftSourceKind {
|
nonisolated var kind: MinecraftSourceKind {
|
||||||
switch self {
|
switch self {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return .localFolder
|
return .localFolder
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return .connectedDevice
|
return .connectedDevice
|
||||||
@ -67,7 +79,7 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
|||||||
|
|
||||||
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
|
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
|
||||||
switch self {
|
switch self {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return .eagerFullScan
|
return .eagerFullScan
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return .staged
|
return .staged
|
||||||
@ -76,12 +88,35 @@ nonisolated enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
|||||||
|
|
||||||
nonisolated var defaultCapabilities: SourceCapabilities {
|
nonisolated var defaultCapabilities: SourceCapabilities {
|
||||||
switch self {
|
switch self {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return .localFolder
|
return .localFolder
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return .connectedDevice
|
return .connectedDevice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func defaultAccessStatus(displayName: String) -> SourceAccessStatus {
|
||||||
|
switch self {
|
||||||
|
case .localFolder(let bookmarkData), .javaLocalFolder(let bookmarkData):
|
||||||
|
return SourceAccessStatus(
|
||||||
|
availability: .unknown,
|
||||||
|
mode: bookmarkData == nil ? .localFileSystem : .securityScopedLocalFolder,
|
||||||
|
displayName: displayName,
|
||||||
|
iconSystemName: "folder",
|
||||||
|
statusText: nil,
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
|
case .connectedDevice(let device, _):
|
||||||
|
return SourceAccessStatus(
|
||||||
|
availability: .unknown,
|
||||||
|
mode: device.connection == .usb ? .usbDevice : .networkDevice,
|
||||||
|
displayName: displayName,
|
||||||
|
iconSystemName: "iphone.gen3",
|
||||||
|
statusText: nil,
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
nonisolated enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
||||||
|
|||||||
@ -24,6 +24,114 @@ nonisolated struct SourceAccessDescriptor: Hashable, Sendable, Codable {
|
|||||||
var refreshStrategy: SourceRefreshStrategy
|
var refreshStrategy: SourceRefreshStrategy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated enum SourceAccessMode: String, Hashable, Sendable, Codable {
|
||||||
|
case localFileSystem
|
||||||
|
case securityScopedLocalFolder
|
||||||
|
case usbDevice
|
||||||
|
case networkDevice
|
||||||
|
case archive
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct SourceAccessStatus: Hashable, Sendable, Codable {
|
||||||
|
var availability: SourceAvailability
|
||||||
|
var mode: SourceAccessMode
|
||||||
|
var displayName: String
|
||||||
|
var iconSystemName: String
|
||||||
|
var statusText: 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 struct SourceCandidate: Identifiable, Hashable, Sendable {
|
||||||
|
var providerID: PlatformProviderID
|
||||||
|
var edition: MinecraftEdition
|
||||||
|
var sourceRootURL: URL
|
||||||
|
var displayName: String
|
||||||
|
var confidence: SourceProbeConfidence
|
||||||
|
var reason: String
|
||||||
|
var detectedKinds: Set<MinecraftContentKind>
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
[
|
||||||
|
providerID,
|
||||||
|
sourceIdentityKey(for: sourceRootURL)
|
||||||
|
].joined(separator: "::")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func sourceIdentityKey(for url: URL) -> String {
|
||||||
|
if url.isFileURL {
|
||||||
|
return url.standardizedFileURL.resolvingSymlinksInPath().path.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.standardized.absoluteString.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
|
||||||
|
case pending
|
||||||
|
case running
|
||||||
|
case succeeded
|
||||||
|
case failed
|
||||||
|
case skipped
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum WorkProgress: Hashable, Sendable, Codable {
|
||||||
|
case indeterminate
|
||||||
|
case fraction(Double)
|
||||||
|
case count(completed: Int, total: Int?)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct WorkStage: Identifiable, Hashable, Sendable, Codable {
|
||||||
|
let id: String
|
||||||
|
var title: String
|
||||||
|
var detail: String?
|
||||||
|
var state: WorkStageState
|
||||||
|
var progress: WorkProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct ProviderWarning: Identifiable, Hashable, Sendable, Codable {
|
||||||
|
let id: String
|
||||||
|
var message: String
|
||||||
|
var detail: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum ProviderEvent: Sendable {
|
||||||
|
case accessStatusChanged(SourceAccessStatus)
|
||||||
|
case stageUpdated(WorkStage)
|
||||||
|
case discovered(MinecraftContentItem)
|
||||||
|
case inspected(MinecraftContentItem)
|
||||||
|
case warning(ProviderWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated enum SourceCandidateEvent: Sendable {
|
||||||
|
case stageUpdated(WorkStage)
|
||||||
|
case candidate(SourceCandidate)
|
||||||
|
case warning(ProviderWarning)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
|
nonisolated struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
|
||||||
let id: URL
|
let id: URL
|
||||||
var displayName: String
|
var displayName: String
|
||||||
|
|||||||
@ -16,7 +16,7 @@ struct ContentItemActionService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType {
|
nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType {
|
||||||
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
|
UTType(filenameExtension: item.capabilities.portablePackageExtension ?? item.contentType.archiveExtension) ?? .data
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func persistExternalRepresentation(
|
nonisolated func persistExternalRepresentation(
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +50,7 @@ enum ContentPackageExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
|
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
|
||||||
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
|
"\(suggestedBaseFilename(for: item)).\(archiveExtension(for: item))"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
|
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
|
||||||
@ -209,7 +213,7 @@ enum ContentPackageExporter {
|
|||||||
|
|
||||||
return requestDirectoryURL
|
return requestDirectoryURL
|
||||||
.appendingPathComponent(suggestedBaseFilename(for: item))
|
.appendingPathComponent(suggestedBaseFilename(for: item))
|
||||||
.appendingPathExtension(item.contentType.archiveExtension)
|
.appendingPathExtension(archiveExtension(for: item))
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String {
|
nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String {
|
||||||
@ -271,7 +275,7 @@ enum ContentPackageExporter {
|
|||||||
|
|
||||||
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
|
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
|
||||||
let normalizedDestinationURL = destinationURL.standardizedFileURL
|
let normalizedDestinationURL = destinationURL.standardizedFileURL
|
||||||
let requiredExtension = item.contentType.archiveExtension
|
let requiredExtension = archiveExtension(for: item)
|
||||||
|
|
||||||
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
|
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
|
||||||
return normalizedDestinationURL
|
return normalizedDestinationURL
|
||||||
@ -280,6 +284,32 @@ enum ContentPackageExporter {
|
|||||||
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
|
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func archiveExtension(for item: MinecraftContentItem) -> String {
|
||||||
|
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,
|
||||||
|
|||||||
@ -1,361 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
nonisolated struct InstallationPayload: Sendable {
|
|
||||||
let contentType: MinecraftContentType
|
|
||||||
let preparedDirectoryURL: URL
|
|
||||||
let displayName: String
|
|
||||||
let packUUID: String?
|
|
||||||
let packVersion: String?
|
|
||||||
let suggestedFolderName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated struct InstallationPlan: Sendable {
|
|
||||||
let payloads: [InstallationPayload]
|
|
||||||
let stagingRootURL: URL
|
|
||||||
|
|
||||||
func cleanup(fileManager: FileManager = .default) {
|
|
||||||
try? fileManager.removeItem(at: stagingRootURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated struct InstalledContentItem: Sendable {
|
|
||||||
let contentType: MinecraftContentType
|
|
||||||
let displayName: String
|
|
||||||
let destinationName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated struct SourceInstallationState: Hashable, Sendable {
|
|
||||||
let completedCount: Int
|
|
||||||
let totalCount: Int
|
|
||||||
let status: String
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MinecraftPackageInstaller {
|
|
||||||
enum InstallError: LocalizedError {
|
|
||||||
case emptyPackage
|
|
||||||
case unsupportedAddonLayout
|
|
||||||
case archiveTooLarge
|
|
||||||
case tooManyEntries
|
|
||||||
case symbolicLinksNotSupported
|
|
||||||
case invalidPreparedContent
|
|
||||||
case duplicatePack(name: String, version: String?)
|
|
||||||
case duplicateArchivePath(String)
|
|
||||||
case partialInstallation(completed: Int, total: Int, reason: String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .emptyPackage:
|
|
||||||
return "The Minecraft package does not contain any files."
|
|
||||||
case .unsupportedAddonLayout:
|
|
||||||
return "The .mcaddon package does not contain any supported Minecraft package files."
|
|
||||||
case .archiveTooLarge:
|
|
||||||
return "The expanded Minecraft package is too large to import safely."
|
|
||||||
case .tooManyEntries:
|
|
||||||
return "The Minecraft package contains too many files to import safely."
|
|
||||||
case .symbolicLinksNotSupported:
|
|
||||||
return "Minecraft packages containing symbolic links are not supported."
|
|
||||||
case .invalidPreparedContent:
|
|
||||||
return "The prepared content does not contain a valid Minecraft world, pack, or template."
|
|
||||||
case .duplicatePack(let name, let version):
|
|
||||||
if let version, !version.isEmpty {
|
|
||||||
return "\(name) version \(version) is already installed in this source."
|
|
||||||
}
|
|
||||||
return "\(name) is already installed in this source."
|
|
||||||
case .duplicateArchivePath(let path):
|
|
||||||
return "The Minecraft package contains more than one entry for \(path)."
|
|
||||||
case .partialInstallation(let completed, let total, let reason):
|
|
||||||
return "Installed \(completed) of \(total) items before the import stopped: \(reason)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let maximumEntryCount = 100_000
|
|
||||||
private static let maximumExpandedSize: UInt64 = 16 * 1_024 * 1_024 * 1_024
|
|
||||||
|
|
||||||
nonisolated static func preparePackage(at packageURL: URL) throws -> InstallationPlan {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let stagingRootURL = fileManager.temporaryDirectory
|
|
||||||
.appendingPathComponent("MinecraftPackageInstallation", isDirectory: true)
|
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
do {
|
|
||||||
let payloads = try preparePackage(
|
|
||||||
at: packageURL.standardizedFileURL,
|
|
||||||
inside: stagingRootURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
guard !payloads.isEmpty else {
|
|
||||||
throw InstallError.emptyPackage
|
|
||||||
}
|
|
||||||
return InstallationPlan(payloads: payloads, stagingRootURL: stagingRootURL)
|
|
||||||
} catch {
|
|
||||||
try? fileManager.removeItem(at: stagingRootURL)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func prepareDirectory(
|
|
||||||
at directoryURL: URL,
|
|
||||||
contentType: MinecraftContentType
|
|
||||||
) throws -> InstallationPlan {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let stagingRootURL = fileManager.temporaryDirectory
|
|
||||||
.appendingPathComponent("MinecraftDirectoryInstallation", isDirectory: true)
|
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
let payloadDirectoryURL = stagingRootURL.appendingPathComponent("payload", isDirectory: true)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try validateDirectoryTree(at: directoryURL, fileManager: fileManager)
|
|
||||||
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
|
||||||
try fileManager.copyItem(at: directoryURL, to: payloadDirectoryURL)
|
|
||||||
let payload = try makePayload(
|
|
||||||
contentRootURL: payloadDirectoryURL,
|
|
||||||
contentType: contentType,
|
|
||||||
fallbackName: directoryURL.lastPathComponent,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
return InstallationPlan(payloads: [payload], stagingRootURL: stagingRootURL)
|
|
||||||
} catch {
|
|
||||||
try? fileManager.removeItem(at: stagingRootURL)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func preparePackage(
|
|
||||||
at packageURL: URL,
|
|
||||||
inside stagingRootURL: URL,
|
|
||||||
fileManager: FileManager
|
|
||||||
) throws -> [InstallationPayload] {
|
|
||||||
let pathExtension = packageURL.pathExtension.lowercased()
|
|
||||||
guard MinecraftPackageInspector.supportedPathExtensions.contains(pathExtension) else {
|
|
||||||
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
|
|
||||||
}
|
|
||||||
|
|
||||||
let archive = try ZipArchiveReader(url: packageURL)
|
|
||||||
try validate(entries: archive.entries)
|
|
||||||
|
|
||||||
if pathExtension == "mcaddon" {
|
|
||||||
return try prepareAddon(
|
|
||||||
archive: archive,
|
|
||||||
packageURL: packageURL,
|
|
||||||
stagingRootURL: stagingRootURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentRootPath = try MinecraftPackageInspector.resolvedContentRootPath(
|
|
||||||
in: archive.entries,
|
|
||||||
archivePathExtension: pathExtension
|
|
||||||
)
|
|
||||||
let payloadDirectoryURL = stagingRootURL
|
|
||||||
.appendingPathComponent("payload-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
try extract(
|
|
||||||
archive: archive,
|
|
||||||
contentRootPath: contentRootPath,
|
|
||||||
to: payloadDirectoryURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
|
|
||||||
let contentType: MinecraftContentType
|
|
||||||
switch pathExtension {
|
|
||||||
case "mcworld":
|
|
||||||
contentType = .world
|
|
||||||
case "mctemplate":
|
|
||||||
contentType = .worldTemplate
|
|
||||||
case "mcpack":
|
|
||||||
contentType = MinecraftContentMetadataReader.inferredPackContentType(
|
|
||||||
for: payloadDirectoryURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
throw MinecraftPackageInspector.InspectionError.unsupportedFileType(pathExtension)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
try makePayload(
|
|
||||||
contentRootURL: payloadDirectoryURL,
|
|
||||||
contentType: contentType,
|
|
||||||
fallbackName: packageURL.deletingPathExtension().lastPathComponent,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func prepareAddon(
|
|
||||||
archive: ZipArchiveReader,
|
|
||||||
packageURL: URL,
|
|
||||||
stagingRootURL: URL,
|
|
||||||
fileManager: FileManager
|
|
||||||
) throws -> [InstallationPayload] {
|
|
||||||
let nestedEntries = archive.entries.filter { entry in
|
|
||||||
guard !entry.isDirectory else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ["mcpack", "mcworld", "mctemplate"].contains(
|
|
||||||
URL(fileURLWithPath: entry.path).pathExtension.lowercased()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !nestedEntries.isEmpty else {
|
|
||||||
throw InstallError.unsupportedAddonLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
var payloads: [InstallationPayload] = []
|
|
||||||
for nestedEntry in nestedEntries {
|
|
||||||
let nestedPackageURL = stagingRootURL
|
|
||||||
.appendingPathComponent("nested-\(UUID().uuidString)")
|
|
||||||
.appendingPathExtension(URL(fileURLWithPath: nestedEntry.path).pathExtension)
|
|
||||||
try archive.extract(nestedEntry).write(to: nestedPackageURL, options: .atomic)
|
|
||||||
payloads.append(
|
|
||||||
contentsOf: try preparePackage(
|
|
||||||
at: nestedPackageURL,
|
|
||||||
inside: stagingRootURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try? fileManager.removeItem(at: nestedPackageURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = packageURL
|
|
||||||
return payloads
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func validate(entries: [ZipArchiveEntry]) throws {
|
|
||||||
guard !entries.isEmpty else {
|
|
||||||
throw InstallError.emptyPackage
|
|
||||||
}
|
|
||||||
guard entries.count <= maximumEntryCount else {
|
|
||||||
throw InstallError.tooManyEntries
|
|
||||||
}
|
|
||||||
guard !entries.contains(where: \.isSymbolicLink) else {
|
|
||||||
throw InstallError.symbolicLinksNotSupported
|
|
||||||
}
|
|
||||||
var paths = Set<String>()
|
|
||||||
for entry in entries where !entry.isDirectory {
|
|
||||||
guard paths.insert(entry.path).inserted else {
|
|
||||||
throw InstallError.duplicateArchivePath(entry.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let expandedSize = entries.reduce(UInt64(0)) { partial, entry in
|
|
||||||
partial + UInt64(entry.uncompressedSize)
|
|
||||||
}
|
|
||||||
guard expandedSize <= maximumExpandedSize else {
|
|
||||||
throw InstallError.archiveTooLarge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func extract(
|
|
||||||
archive: ZipArchiveReader,
|
|
||||||
contentRootPath: String,
|
|
||||||
to destinationRootURL: URL,
|
|
||||||
fileManager: FileManager
|
|
||||||
) throws {
|
|
||||||
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
|
|
||||||
let prefix = contentRootPath.isEmpty ? "" : contentRootPath + "/"
|
|
||||||
|
|
||||||
for entry in archive.entries {
|
|
||||||
guard entry.path.hasPrefix(prefix) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let relativePath = String(entry.path.dropFirst(prefix.count))
|
|
||||||
guard !relativePath.isEmpty else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let destinationURL = destinationRootURL.appendingPathComponent(relativePath)
|
|
||||||
if entry.isDirectory {
|
|
||||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try fileManager.createDirectory(
|
|
||||||
at: destinationURL.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true
|
|
||||||
)
|
|
||||||
try archive.extract(entry).write(to: destinationURL, options: .atomic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func makePayload(
|
|
||||||
contentRootURL: URL,
|
|
||||||
contentType: MinecraftContentType,
|
|
||||||
fallbackName: String,
|
|
||||||
fileManager: FileManager
|
|
||||||
) throws -> InstallationPayload {
|
|
||||||
guard isValidContent(at: contentRootURL, type: contentType, fileManager: fileManager) else {
|
|
||||||
throw InstallError.invalidPreparedContent
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest = MinecraftContentMetadataReader.manifestMetadata(
|
|
||||||
in: contentRootURL,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
let displayName = MinecraftContentMetadataReader.displayName(
|
|
||||||
for: contentRootURL,
|
|
||||||
contentType: contentType,
|
|
||||||
fallbackName: fallbackName,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
let preferredFolderName = manifest?.uuid ?? fallbackName
|
|
||||||
|
|
||||||
return InstallationPayload(
|
|
||||||
contentType: contentType,
|
|
||||||
preparedDirectoryURL: contentRootURL,
|
|
||||||
displayName: displayName,
|
|
||||||
packUUID: manifest?.uuid,
|
|
||||||
packVersion: manifest?.version,
|
|
||||||
suggestedFolderName: sanitizedFolderName(preferredFolderName)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func isValidContent(
|
|
||||||
at directoryURL: URL,
|
|
||||||
type: MinecraftContentType,
|
|
||||||
fileManager: FileManager
|
|
||||||
) -> Bool {
|
|
||||||
switch type {
|
|
||||||
case .world:
|
|
||||||
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path)
|
|
||||||
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path)
|
|
||||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
|
||||||
return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func sanitizedFolderName(_ value: String) -> String {
|
|
||||||
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
|
||||||
let components = value.components(separatedBy: invalidCharacters)
|
|
||||||
let sanitized = components.joined(separator: "-")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return sanitized.isEmpty ? UUID().uuidString : String(sanitized.prefix(80))
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func validateDirectoryTree(
|
|
||||||
at directoryURL: URL,
|
|
||||||
fileManager: FileManager
|
|
||||||
) throws {
|
|
||||||
guard let enumerator = fileManager.enumerator(
|
|
||||||
at: directoryURL,
|
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey],
|
|
||||||
options: []
|
|
||||||
) else {
|
|
||||||
throw InstallError.invalidPreparedContent
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryCount = 0
|
|
||||||
for case let entryURL as URL in enumerator {
|
|
||||||
entryCount += 1
|
|
||||||
guard entryCount <= maximumEntryCount else {
|
|
||||||
throw InstallError.tooManyEntries
|
|
||||||
}
|
|
||||||
if (try entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true {
|
|
||||||
throw InstallError.symbolicLinksNotSupported
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum WorldScanner {
|
typealias WorldScanner = BedrockContentScanner
|
||||||
|
|
||||||
|
enum BedrockContentScanner {
|
||||||
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var sizedItem = item
|
var sizedItem = item
|
||||||
@ -20,6 +22,49 @@ enum WorldScanner {
|
|||||||
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 }
|
||||||
@ -162,7 +207,7 @@ enum WorldScanner {
|
|||||||
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
|
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
|
||||||
: nil
|
: nil
|
||||||
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
|
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
|
||||||
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
||||||
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||||
enrichedItem.packUUID = manifestMetadata.uuid
|
enrichedItem.packUUID = manifestMetadata.uuid
|
||||||
enrichedItem.packVersion = manifestMetadata.version
|
enrichedItem.packVersion = manifestMetadata.version
|
||||||
@ -322,11 +367,11 @@ enum WorldScanner {
|
|||||||
return worldMetadata?.lastPlayedDate
|
return worldMetadata?.lastPlayedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? {
|
nonisolated fileprivate static func modifiedDate(for directoryURL: URL) -> Date? {
|
||||||
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
|
nonisolated fileprivate static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
|
||||||
guard let enumerator = fileManager.enumerator(
|
guard let enumerator = fileManager.enumerator(
|
||||||
at: folderURL,
|
at: folderURL,
|
||||||
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
|
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
|
||||||
@ -580,4 +625,596 @@ 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 discoverSourceCandidates(
|
||||||
|
providerID: PlatformProviderID,
|
||||||
|
searchRoots: [URL]? = nil,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [SourceCandidate] {
|
||||||
|
let roots = uniqueStandardizedURLs(searchRoots ?? defaultCandidateSearchRoots(fileManager: fileManager))
|
||||||
|
.map(\.standardizedFileURL)
|
||||||
|
.filter { fileManager.fileExists(atPath: $0.path) }
|
||||||
|
|
||||||
|
var candidatesByID: [String: SourceCandidate] = [:]
|
||||||
|
for root in roots {
|
||||||
|
let candidateFolders = boundedCandidateFolders(from: root, maxDepth: 4, maxFolderCount: 600, fileManager: fileManager)
|
||||||
|
var candidatesForRoot: [SourceCandidate] = []
|
||||||
|
for folderURL in candidateFolders {
|
||||||
|
guard let probe = probeLocalFolder(folderURL, providerID: providerID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = SourceCandidate(
|
||||||
|
providerID: probe.providerID,
|
||||||
|
edition: probe.edition,
|
||||||
|
sourceRootURL: probe.sourceRootURL,
|
||||||
|
displayName: probe.displayName,
|
||||||
|
confidence: probe.confidence,
|
||||||
|
reason: "Found Java markers near \(root.lastPathComponent)",
|
||||||
|
detectedKinds: probe.detectedKinds
|
||||||
|
)
|
||||||
|
|
||||||
|
candidatesForRoot.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in collapsedCandidates(
|
||||||
|
candidatesForRoot,
|
||||||
|
under: root,
|
||||||
|
providerID: providerID
|
||||||
|
) {
|
||||||
|
if let existingCandidate = candidatesByID[candidate.id],
|
||||||
|
existingCandidate.confidence >= candidate.confidence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidatesByID[candidate.id] = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidatesByID.values.sorted {
|
||||||
|
if $0.confidence != $1.confidence {
|
||||||
|
return $0.confidence > $1.confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func discoverItems(
|
||||||
|
in searchRootURL: URL,
|
||||||
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
|
) throws -> [MinecraftContentItem] {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
|
||||||
|
for scanRootURL in contentScanRoots(for: searchRootURL, fileManager: fileManager) {
|
||||||
|
let savesRootURL = existingDirectory(
|
||||||
|
named: "saves",
|
||||||
|
in: scanRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
) ?? scanRootURL
|
||||||
|
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
|
||||||
|
discoveredItems.append(contentsOf: worldItems)
|
||||||
|
|
||||||
|
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager) {
|
||||||
|
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
|
||||||
|
discoveredItems.append(contentsOf: resourcePackItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dataPacksURL = existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager) {
|
||||||
|
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
||||||
|
in: dataPacksURL,
|
||||||
|
contentKind: .dataPack,
|
||||||
|
platformType: .dataPack,
|
||||||
|
packageExtension: "zip",
|
||||||
|
fileManager: fileManager
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager) {
|
||||||
|
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
||||||
|
in: shaderPacksURL,
|
||||||
|
contentKind: .shaderPack,
|
||||||
|
platformType: .shaderPack,
|
||||||
|
packageExtension: "zip",
|
||||||
|
fileManager: fileManager
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let modsURL = existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager) {
|
||||||
|
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
||||||
|
in: modsURL,
|
||||||
|
contentKind: .mod,
|
||||||
|
platformType: .mod,
|
||||||
|
packageExtension: "jar",
|
||||||
|
fileManager: fileManager
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredItems.sort(by: WorldScanner.sortItems)
|
||||||
|
discoveredItems.forEach(onDiscovered)
|
||||||
|
return discoveredItems
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
|
||||||
|
var enrichedItem = item
|
||||||
|
let metadata = JavaContentMetadataReader.metadata(for: item)
|
||||||
|
enrichedItem.displayName = metadata?.displayName ?? displayName(for: item)
|
||||||
|
enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata)
|
||||||
|
if metadata?.pack != nil || metadata?.mod != nil {
|
||||||
|
enrichedItem.platformMetadata = .java(JavaContentMetadata(
|
||||||
|
pack: metadata?.pack,
|
||||||
|
mod: metadata?.mod
|
||||||
|
))
|
||||||
|
}
|
||||||
|
enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil
|
||||||
|
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
||||||
|
enrichedItem.metadataLoaded = true
|
||||||
|
enrichedItem.previewLoaded = true
|
||||||
|
enrichedItem.sizeLoaded = false
|
||||||
|
return enrichedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func loadSize(for item: MinecraftContentItem) -> MinecraftContentItem {
|
||||||
|
var sizedItem = item
|
||||||
|
sizedItem.sizeBytes = contentSize(at: item.folderURL, fileManager: .default)
|
||||||
|
sizedItem.sizeLoaded = true
|
||||||
|
return sizedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var snapshots: [CollectionSnapshot] = []
|
||||||
|
for scanRootURL in contentScanRoots(for: sourceRootURL, fileManager: fileManager) {
|
||||||
|
let candidateRoots = [
|
||||||
|
existingDirectory(named: "saves", in: scanRootURL, fileManager: fileManager),
|
||||||
|
existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager),
|
||||||
|
existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager),
|
||||||
|
existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager),
|
||||||
|
existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager)
|
||||||
|
]
|
||||||
|
|
||||||
|
for collectionURL in candidateRoots {
|
||||||
|
guard let collectionURL else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let snapshot = collectionSnapshot(
|
||||||
|
for: collectionURL,
|
||||||
|
sourceRootURL: sourceRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
) {
|
||||||
|
snapshots.append(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
||||||
|
let worldDirectories = try WorldScanner.immediateChildDirectories(of: savesRootURL, fileManager: fileManager)
|
||||||
|
return worldDirectories.compactMap { worldURL in
|
||||||
|
guard fileManager.fileExists(atPath: worldURL.appendingPathComponent("level.dat").path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return MinecraftContentItem(
|
||||||
|
folderURL: worldURL,
|
||||||
|
folderName: worldURL.lastPathComponent,
|
||||||
|
contentType: .world,
|
||||||
|
sourceEdition: .java,
|
||||||
|
contentKind: .world,
|
||||||
|
platformType: .java(.world),
|
||||||
|
collectionRootURL: savesRootURL,
|
||||||
|
capabilities: .java(contentType: .world),
|
||||||
|
platformMetadata: .java(JavaContentMetadata())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func discoverResourcePacks(in resourcePacksURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
||||||
|
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 javaContentItem(
|
||||||
|
url: childURL,
|
||||||
|
contentKind: contentKind,
|
||||||
|
platformType: platformType,
|
||||||
|
collectionRootURL: collectionURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func existingDirectory(named name: String, in rootURL: URL, fileManager: FileManager) -> URL? {
|
||||||
|
let directoryURL = rootURL.appendingPathComponent(name, isDirectory: true)
|
||||||
|
guard (try? directoryURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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: []
|
||||||
|
)) ?? []
|
||||||
|
candidates.append(contentsOf: children.filter {
|
||||||
|
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||||
|
})
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func collapsedCandidates(
|
||||||
|
_ candidates: [SourceCandidate],
|
||||||
|
under root: URL,
|
||||||
|
providerID: PlatformProviderID
|
||||||
|
) -> [SourceCandidate] {
|
||||||
|
let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.sourceRootURL) }).compactMap { _, groupedCandidates in
|
||||||
|
groupedCandidates.max { lhs, rhs in
|
||||||
|
lhs.confidence < rhs.confidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard uniqueCandidates.count > 1 else {
|
||||||
|
return uniqueCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
let detectedKinds = uniqueCandidates.reduce(into: Set<MinecraftContentKind>()) { result, candidate in
|
||||||
|
result.formUnion(candidate.detectedKinds)
|
||||||
|
}
|
||||||
|
let confidence = uniqueCandidates.map(\.confidence).max() ?? .medium
|
||||||
|
let standardizedRoot = root.standardizedFileURL
|
||||||
|
|
||||||
|
return [
|
||||||
|
SourceCandidate(
|
||||||
|
providerID: providerID,
|
||||||
|
edition: .java,
|
||||||
|
sourceRootURL: standardizedRoot,
|
||||||
|
displayName: standardizedRoot.lastPathComponent,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: "Found multiple Java sources under \(standardizedRoot.lastPathComponent)",
|
||||||
|
detectedKinds: detectedKinds
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set<MinecraftContentKind>) {
|
||||||
|
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 defaultCandidateSearchRoots(fileManager: FileManager) -> [URL] {
|
||||||
|
let homeURL = fileManager.homeDirectoryForCurrentUser
|
||||||
|
let applicationSupportURL = homeURL
|
||||||
|
.appendingPathComponent("Library", isDirectory: true)
|
||||||
|
.appendingPathComponent("Application Support", isDirectory: true)
|
||||||
|
let documentsURL = homeURL.appendingPathComponent("Documents", isDirectory: true)
|
||||||
|
|
||||||
|
return [
|
||||||
|
applicationSupportURL.appendingPathComponent("minecraft", isDirectory: true),
|
||||||
|
documentsURL.appendingPathComponent("curseforge/minecraft", isDirectory: true),
|
||||||
|
documentsURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("PrismLauncher/instances", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("MultiMC/instances", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("PolyMC/instances", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("com.modrinth.theseus/profiles", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("ATLauncher/instances", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("gdlauncher_next/instances", isDirectory: true),
|
||||||
|
applicationSupportURL.appendingPathComponent("GDLauncher_next/instances", isDirectory: true)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func boundedCandidateFolders(
|
||||||
|
from rootURL: URL,
|
||||||
|
maxDepth: Int,
|
||||||
|
maxFolderCount: Int,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> [URL] {
|
||||||
|
var folders: [URL] = []
|
||||||
|
var queue: [(url: URL, depth: Int)] = [(rootURL, 0)]
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
while !queue.isEmpty && folders.count < maxFolderCount {
|
||||||
|
let current = queue.removeFirst()
|
||||||
|
let normalizedURL = current.url.standardizedFileURL
|
||||||
|
guard seen.insert(sourceIdentityKey(for: normalizedURL)).inserted else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
folders.append(normalizedURL)
|
||||||
|
guard current.depth < maxDepth else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: normalizedURL,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: []
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
let childDirectories = children
|
||||||
|
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.append(contentsOf: childDirectories.map { ($0, current.depth + 1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func contentScanRoots(for sourceRootURL: URL, fileManager: FileManager) -> [URL] {
|
||||||
|
let standardizedRoot = sourceRootURL.standardizedFileURL
|
||||||
|
if javaProbeScore(for: standardizedRoot, fileManager: fileManager).value > 0 {
|
||||||
|
return [standardizedRoot]
|
||||||
|
}
|
||||||
|
|
||||||
|
let discoveredRoots = boundedCandidateFolders(
|
||||||
|
from: standardizedRoot,
|
||||||
|
maxDepth: 4,
|
||||||
|
maxFolderCount: 600,
|
||||||
|
fileManager: fileManager
|
||||||
|
).filter { candidateURL in
|
||||||
|
candidateURL != standardizedRoot
|
||||||
|
&& javaProbeScore(for: candidateURL, fileManager: fileManager).value > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueStandardizedURLs(discoveredRoots).sorted {
|
||||||
|
$0.path.localizedStandardCompare($1.path) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func uniqueStandardizedURLs(_ urls: [URL]) -> [URL] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [URL] = []
|
||||||
|
result.reserveCapacity(urls.count)
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
let standardizedURL = url.standardizedFileURL
|
||||||
|
guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(standardizedURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func collectionSnapshot(
|
||||||
|
for collectionURL: URL,
|
||||||
|
sourceRootURL: URL,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> CollectionSnapshot? {
|
||||||
|
guard fileManager.fileExists(atPath: collectionURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: collectionURL,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)) ?? []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return (childURL.lastPathComponent, values?.contentModificationDate, values?.fileSize)
|
||||||
|
}.sorted {
|
||||||
|
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
|
||||||
|
let childFingerprint = childSnapshots.map { child in
|
||||||
|
[
|
||||||
|
child.name,
|
||||||
|
child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
|
child.size.map(String.init) ?? "nil"
|
||||||
|
].joined(separator: "@")
|
||||||
|
}.joined(separator: "|")
|
||||||
|
|
||||||
|
let folderName = relativePath(from: sourceRootURL.standardizedFileURL, to: collectionURL.standardizedFileURL)
|
||||||
|
?? collectionURL.lastPathComponent
|
||||||
|
|
||||||
|
return CollectionSnapshot(
|
||||||
|
folderName: folderName,
|
||||||
|
modifiedDate: modifiedDate,
|
||||||
|
childDirectoryCount: childSnapshots.count,
|
||||||
|
fingerprint: [
|
||||||
|
folderName,
|
||||||
|
String(childSnapshots.count),
|
||||||
|
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
|
childFingerprint
|
||||||
|
].joined(separator: "::")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func relativePath(from rootURL: URL, to childURL: URL) -> String? {
|
||||||
|
let rootPath = rootURL.standardizedFileURL.path
|
||||||
|
let childPath = childURL.standardizedFileURL.path
|
||||||
|
guard childPath.hasPrefix(rootPath + "/") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(childPath.dropFirst(rootPath.count + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func displayName(for item: MinecraftContentItem) -> String {
|
||||||
|
guard item.contentKind == .world else {
|
||||||
|
return item.folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
|
||||||
|
guard
|
||||||
|
let value = try? String(contentsOf: levelNameURL, encoding: .utf8)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!value.isEmpty
|
||||||
|
else {
|
||||||
|
return item.folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let packReferenceIndexStore = PackReferenceIndexStore()
|
private let packReferenceIndexStore = PackReferenceIndexStore()
|
||||||
|
|||||||
@ -0,0 +1,422 @@
|
|||||||
|
// 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 mod: JavaModMetadata?
|
||||||
|
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,
|
||||||
|
mod: nil,
|
||||||
|
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,
|
||||||
|
mod: modMetadata?.metadata,
|
||||||
|
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,
|
||||||
|
supportedFormats: supportedFormatsValue(from: packObject["supported_formats"]),
|
||||||
|
description: textValue(from: packObject["description"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func modMetadata(
|
||||||
|
from archive: ZipArchiveReader
|
||||||
|
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||||
|
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?, metadata: JavaModMetadata)? {
|
||||||
|
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 dependenciesSection = firstTOMLSection(named: "[[dependencies.", in: text)
|
||||||
|
let displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
|
||||||
|
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
|
||||||
|
let metadata = JavaModMetadata(
|
||||||
|
modID: tomlStringValue(forKey: "modId", in: firstModSection),
|
||||||
|
version: tomlStringValue(forKey: "version", in: firstModSection),
|
||||||
|
description: tomlStringValue(forKey: "description", in: firstModSection),
|
||||||
|
authors: stringListValue(from: tomlStringValue(forKey: "authors", in: firstModSection)),
|
||||||
|
license: tomlStringValue(forKey: "license", in: text),
|
||||||
|
environment: nil,
|
||||||
|
minecraftVersionRequirement: minecraftDependencyRequirement(fromTOMLSection: dependenciesSection)
|
||||||
|
)
|
||||||
|
if displayName != nil || logoFile != nil || metadata.hasValues {
|
||||||
|
return (displayName, logoFile, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func modJSONMetadata(
|
||||||
|
from archive: ZipArchiveReader,
|
||||||
|
entryName: String
|
||||||
|
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = JavaModMetadata(
|
||||||
|
modID: (jsonObject["id"] as? String)?.nilIfBlank,
|
||||||
|
version: (jsonObject["version"] as? String)?.nilIfBlank,
|
||||||
|
description: textValue(from: jsonObject["description"]),
|
||||||
|
authors: authorsValue(from: jsonObject["authors"]),
|
||||||
|
license: licenseValue(from: jsonObject["license"]),
|
||||||
|
environment: (jsonObject["environment"] as? String)?.nilIfBlank,
|
||||||
|
minecraftVersionRequirement: minecraftDependencyRequirement(fromJSON: jsonObject)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(jsonObject["name"] as? String)?.nilIfBlank,
|
||||||
|
iconPath?.nilIfBlank,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 minecraftDependencyRequirement(fromTOMLSection text: String) -> String? {
|
||||||
|
guard tomlStringValue(forKey: "modId", in: text) == "minecraft" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return tomlStringValue(forKey: "versionRange", in: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func minecraftDependencyRequirement(fromJSON jsonObject: [String: Any]) -> String? {
|
||||||
|
for key in ["depends", "dependencies", "breaks"] {
|
||||||
|
guard let dependencies = jsonObject[key] as? [String: Any] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let minecraft = dependencies["minecraft"] as? String {
|
||||||
|
return minecraft.nilIfBlank
|
||||||
|
}
|
||||||
|
if let minecraft = dependencies["minecraft"] as? [String: Any] {
|
||||||
|
return textValue(from: minecraft["version"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func authorsValue(from value: Any?) -> [String] {
|
||||||
|
if let author = value as? String {
|
||||||
|
return stringListValue(from: author)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authors = value as? [String] {
|
||||||
|
return authors.compactMap(\.nilIfBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authors = value as? [[String: Any]] {
|
||||||
|
return authors.compactMap { author in
|
||||||
|
textValue(from: author["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func licenseValue(from value: Any?) -> String? {
|
||||||
|
if let license = value as? String {
|
||||||
|
return license.nilIfBlank
|
||||||
|
}
|
||||||
|
|
||||||
|
if let licenses = value as? [String] {
|
||||||
|
let values = licenses.compactMap(\.nilIfBlank)
|
||||||
|
return values.isEmpty ? nil : values.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func stringListValue(from value: String?) -> [String] {
|
||||||
|
guard let value else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split { character in
|
||||||
|
character == "," || character == ";"
|
||||||
|
}
|
||||||
|
.map(String.init)
|
||||||
|
.compactMap(\.nilIfBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func supportedFormatsValue(from value: Any?) -> String? {
|
||||||
|
if let format = value as? Int {
|
||||||
|
return String(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let formats = value as? [Int] {
|
||||||
|
return formats.map(String.init).joined(separator: ", ").nilIfBlank
|
||||||
|
}
|
||||||
|
|
||||||
|
if let object = value as? [String: Any] {
|
||||||
|
let minValue = object["min_inclusive"] as? Int
|
||||||
|
let maxValue = object["max_inclusive"] as? Int
|
||||||
|
switch (minValue, maxValue) {
|
||||||
|
case (.some(let minValue), .some(let maxValue)):
|
||||||
|
return "\(minValue)-\(maxValue)"
|
||||||
|
case (.some(let minValue), .none):
|
||||||
|
return "\(minValue)+"
|
||||||
|
case (.none, .some(let maxValue)):
|
||||||
|
return "Up to \(maxValue)"
|
||||||
|
case (.none, .none):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension JavaModMetadata {
|
||||||
|
nonisolated var hasValues: Bool {
|
||||||
|
modID != nil
|
||||||
|
|| version != nil
|
||||||
|
|| description != nil
|
||||||
|
|| !authors.isEmpty
|
||||||
|
|| license != nil
|
||||||
|
|| environment != nil
|
||||||
|
|| minecraftVersionRequirement != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
nonisolated var nilIfBlank: String? {
|
||||||
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,9 @@ struct MinecraftManifestMetadata: Sendable, Hashable {
|
|||||||
let minimumEngineVersion: String?
|
let minimumEngineVersion: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MinecraftContentMetadataReader {
|
typealias MinecraftContentMetadataReader = BedrockContentMetadataReader
|
||||||
|
|
||||||
|
enum BedrockContentMetadataReader {
|
||||||
nonisolated static func displayName(
|
nonisolated static func displayName(
|
||||||
for directoryURL: URL,
|
for directoryURL: URL,
|
||||||
contentType: MinecraftContentType,
|
contentType: MinecraftContentType,
|
||||||
|
|||||||
@ -147,7 +147,7 @@ enum MinecraftPackageInspector {
|
|||||||
return contentRootURL
|
return contentRootURL
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func resolvedContentRootPath(
|
nonisolated private static func resolvedContentRootPath(
|
||||||
in entries: [ZipArchiveEntry],
|
in entries: [ZipArchiveEntry],
|
||||||
archivePathExtension: String
|
archivePathExtension: String
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
@ -181,7 +181,7 @@ enum MinecraftPackageInspector {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
|
||||||
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
|
||||||
|
|
||||||
let worldMarkers = ["level.dat", "levelname.txt"]
|
let worldMarkers = ["level.dat", "levelname.txt"]
|
||||||
|
|||||||
@ -11,14 +11,12 @@ nonisolated struct ZipArchiveEntry: Sendable, Hashable {
|
|||||||
let uncompressedSize: UInt32
|
let uncompressedSize: UInt32
|
||||||
let localHeaderOffset: UInt32
|
let localHeaderOffset: UInt32
|
||||||
let isDirectory: Bool
|
let isDirectory: Bool
|
||||||
let isSymbolicLink: Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated enum ZipArchiveReaderError: LocalizedError {
|
nonisolated enum ZipArchiveReaderError: LocalizedError {
|
||||||
case invalidArchive
|
case invalidArchive
|
||||||
case unsupportedCompressionMethod(UInt16)
|
case unsupportedCompressionMethod(UInt16)
|
||||||
case unsupportedFeatures(String)
|
case unsupportedFeatures(String)
|
||||||
case unsafeEntryPath(String)
|
|
||||||
case entryNotFound(String)
|
case entryNotFound(String)
|
||||||
case decompressionFailed(Int32)
|
case decompressionFailed(Int32)
|
||||||
|
|
||||||
@ -30,8 +28,6 @@ nonisolated enum ZipArchiveReaderError: LocalizedError {
|
|||||||
return "Unsupported ZIP compression method: \(method)."
|
return "Unsupported ZIP compression method: \(method)."
|
||||||
case .unsupportedFeatures(let message):
|
case .unsupportedFeatures(let message):
|
||||||
return message
|
return message
|
||||||
case .unsafeEntryPath(let path):
|
|
||||||
return "The ZIP archive contains an unsafe entry path: \(path)"
|
|
||||||
case .entryNotFound(let path):
|
case .entryNotFound(let path):
|
||||||
return "ZIP entry not found: \(path)"
|
return "ZIP entry not found: \(path)"
|
||||||
case .decompressionFailed(let code):
|
case .decompressionFailed(let code):
|
||||||
@ -45,7 +41,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let entries: [ZipArchiveEntry]
|
let entries: [ZipArchiveEntry]
|
||||||
|
|
||||||
init(url: URL) throws {
|
init(url: URL) throws {
|
||||||
self.data = try Data(contentsOf: url, options: .mappedIfSafe)
|
self.data = try Data(contentsOf: url)
|
||||||
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
self.entries = try ZipArchiveReader.parseEntries(in: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,9 +78,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
|
|
||||||
switch entry.compressionMethod {
|
switch entry.compressionMethod {
|
||||||
case 0:
|
case 0:
|
||||||
guard compressedData.count == Int(entry.uncompressedSize) else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
return compressedData
|
return compressedData
|
||||||
case 8:
|
case 8:
|
||||||
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
|
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
|
||||||
@ -122,7 +115,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
|
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
|
||||||
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
|
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
|
||||||
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
|
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
|
||||||
let externalAttributes = data.readUInt32LE(at: offset + 38)
|
|
||||||
|
|
||||||
let filenameStart = offset + 46
|
let filenameStart = offset + 46
|
||||||
let filenameEnd = filenameStart + filenameLength
|
let filenameEnd = filenameStart + filenameLength
|
||||||
@ -135,9 +127,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
throw ZipArchiveReaderError.invalidArchive
|
throw ZipArchiveReaderError.invalidArchive
|
||||||
}
|
}
|
||||||
|
|
||||||
try validateEntryPath(filename)
|
|
||||||
let normalizedPath = Self.normalizedPath(filename)
|
let normalizedPath = Self.normalizedPath(filename)
|
||||||
let unixMode = UInt16((externalAttributes >> 16) & 0xffff)
|
|
||||||
entries.append(
|
entries.append(
|
||||||
ZipArchiveEntry(
|
ZipArchiveEntry(
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
@ -145,8 +135,7 @@ nonisolated struct ZipArchiveReader {
|
|||||||
compressedSize: compressedSize,
|
compressedSize: compressedSize,
|
||||||
uncompressedSize: uncompressedSize,
|
uncompressedSize: uncompressedSize,
|
||||||
localHeaderOffset: localHeaderOffset,
|
localHeaderOffset: localHeaderOffset,
|
||||||
isDirectory: normalizedPath.hasSuffix("/"),
|
isDirectory: normalizedPath.hasSuffix("/")
|
||||||
isSymbolicLink: unixMode & 0o170000 == 0o120000
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -189,18 +178,6 @@ nonisolated struct ZipArchiveReader {
|
|||||||
return joined
|
return joined
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func validateEntryPath(_ path: String) throws {
|
|
||||||
let replaced = path.replacingOccurrences(of: "\\", with: "/")
|
|
||||||
let components = replaced.split(separator: "/", omittingEmptySubsequences: false)
|
|
||||||
guard
|
|
||||||
!replaced.hasPrefix("/"),
|
|
||||||
!replaced.contains("\0"),
|
|
||||||
!components.contains(where: { $0 == ".." })
|
|
||||||
else {
|
|
||||||
throw ZipArchiveReaderError.unsafeEntryPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
|
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
|
||||||
if data.isEmpty {
|
if data.isEmpty {
|
||||||
return Data()
|
return Data()
|
||||||
@ -251,17 +228,11 @@ nonisolated struct ZipArchiveReader {
|
|||||||
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
|
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
|
||||||
if producedByteCount > 0 {
|
if producedByteCount > 0 {
|
||||||
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
|
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
|
||||||
guard output.count <= expectedSize else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} while status != Z_STREAM_END
|
} while status != Z_STREAM_END
|
||||||
}
|
}
|
||||||
|
|
||||||
guard output.count == expectedSize else {
|
|
||||||
throw ZipArchiveReaderError.invalidArchive
|
|
||||||
}
|
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,11 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
enum SourceLibraryCommand: Sendable {
|
||||||
|
case discoverSourceCandidates
|
||||||
|
case refreshAllSources
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting {
|
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting {
|
||||||
private static let enrichmentWorkerCount = 4
|
private static let enrichmentWorkerCount = 4
|
||||||
@ -28,11 +33,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||||
|
@Published var sourceCandidates: [SourceCandidate] = []
|
||||||
|
@Published var isDiscoveringSourceCandidates = false
|
||||||
@Published var isRestoringPersistedSources = true
|
@Published var isRestoringPersistedSources = true
|
||||||
@Published var installationStateBySourceID: [URL: SourceInstallationState] = [:]
|
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
||||||
|
private var candidateDiscoveryTask: Task<Void, Never>?
|
||||||
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
||||||
private var localSourceRefreshTask: Task<Void, Never>?
|
private var localSourceRefreshTask: Task<Void, Never>?
|
||||||
private let persistenceStore: SourcePersistenceStore
|
private let persistenceStore: SourcePersistenceStore
|
||||||
@ -52,28 +59,35 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||||
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
|
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
|
||||||
notificationService: ScanNotificationServicing? = nil,
|
notificationService: ScanNotificationServicing? = nil,
|
||||||
itemActionService: ContentItemActionService = ContentItemActionService()
|
itemActionService: ContentItemActionService = ContentItemActionService(),
|
||||||
|
restoresPersistedSources: Bool = true,
|
||||||
|
startsBackgroundRefresh: Bool = true
|
||||||
) {
|
) {
|
||||||
self.persistenceStore = persistenceStore
|
self.persistenceStore = persistenceStore
|
||||||
self.sourceAccessMethod = sourceAccessMethod
|
self.sourceAccessMethod = sourceAccessMethod
|
||||||
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
||||||
self.notificationService = notificationService ?? ScanNotificationService.shared
|
self.notificationService = notificationService ?? ScanNotificationService.shared
|
||||||
self.itemActionService = itemActionService
|
self.itemActionService = itemActionService
|
||||||
|
self.isRestoringPersistedSources = restoresPersistedSources
|
||||||
|
|
||||||
Task { [weak self] in
|
if restoresPersistedSources {
|
||||||
guard let self else {
|
Task { [weak self] in
|
||||||
return
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
||||||
}
|
}
|
||||||
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localSourceRefreshTask = Task { [weak self] in
|
if startsBackgroundRefresh {
|
||||||
await self?.runLocalSourceRefreshLoop()
|
localSourceRefreshTask = Task { [weak self] in
|
||||||
}
|
await self?.runLocalSourceRefreshLoop()
|
||||||
|
}
|
||||||
|
|
||||||
if connectedDeviceAccessMethod != nil {
|
if connectedDeviceAccessMethod != nil {
|
||||||
connectedDeviceRefreshTask = Task { [weak self] in
|
connectedDeviceRefreshTask = Task { [weak self] in
|
||||||
await self?.runConnectedDeviceRefreshLoop()
|
await self?.runConnectedDeviceRefreshLoop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,6 +95,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
deinit {
|
deinit {
|
||||||
connectedDeviceRefreshTask?.cancel()
|
connectedDeviceRefreshTask?.cancel()
|
||||||
localSourceRefreshTask?.cancel()
|
localSourceRefreshTask?.cancel()
|
||||||
|
candidateDiscoveryTask?.cancel()
|
||||||
automaticSyncTasks.values.forEach { $0.cancel() }
|
automaticSyncTasks.values.forEach { $0.cancel() }
|
||||||
scanTasks.values.forEach { $0.cancel() }
|
scanTasks.values.forEach { $0.cancel() }
|
||||||
}
|
}
|
||||||
@ -93,6 +108,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
visibleSources
|
visibleSources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sidebarConnectedDevices: [ConnectedDeviceSidebarEntry] {
|
||||||
|
connectedDevices.filter { entry in
|
||||||
|
guard entry.matchedSourceID == nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !sources.contains { source in
|
||||||
|
guard case .connectedDevice(let device, _) = source.origin else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return device.udid == entry.device.udid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sourceID(forItemID itemID: URL) -> URL? {
|
func sourceID(forItemID itemID: URL) -> URL? {
|
||||||
sourceIDByItemID[itemID]
|
sourceIDByItemID[itemID]
|
||||||
}
|
}
|
||||||
@ -111,6 +142,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
connectedDeviceRefreshTask = nil
|
connectedDeviceRefreshTask = nil
|
||||||
localSourceRefreshTask?.cancel()
|
localSourceRefreshTask?.cancel()
|
||||||
localSourceRefreshTask = nil
|
localSourceRefreshTask = nil
|
||||||
|
candidateDiscoveryTask?.cancel()
|
||||||
|
candidateDiscoveryTask = nil
|
||||||
|
|
||||||
for task in automaticSyncTasks.values {
|
for task in automaticSyncTasks.values {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@ -138,40 +171,121 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSource(at url: URL) -> URL {
|
func perform(_ command: SourceLibraryCommand) {
|
||||||
let normalizedURL = url.standardizedFileURL
|
switch command {
|
||||||
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
|
case .discoverSourceCandidates:
|
||||||
|
discoverSourceCandidates()
|
||||||
|
case .refreshAllSources:
|
||||||
|
for source in visibleSources where source.availability == .available {
|
||||||
|
startScan(for: source.id, mode: .fullScan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if sources.contains(where: { $0.id == normalizedURL }) {
|
func addSource(at url: URL) async -> URL {
|
||||||
updateSource(normalizedURL) { source in
|
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 let existingSourceID = existingSourceID(matching: normalizedURL) {
|
||||||
|
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) }
|
||||||
|
updateSource(existingSourceID) { 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(
|
||||||
|
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: existingSourceID, mode: .fullScan)
|
||||||
return normalizedURL
|
return existingSourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return addSource(source, shouldPersist: true, shouldScan: true)
|
source.providerID = providerID
|
||||||
|
source.edition = edition
|
||||||
|
source.displayName = probe?.displayName ?? normalizedURL.lastPathComponent
|
||||||
|
if let warning = probe?.warnings.first {
|
||||||
|
source.scanDiagnostic = warning
|
||||||
|
}
|
||||||
|
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
|
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: sourceID) }
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSource(candidate: SourceCandidate) async -> URL {
|
||||||
|
let normalizedURL = candidate.sourceRootURL.standardizedFileURL
|
||||||
|
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
|
||||||
|
|
||||||
|
if let existingSourceID = existingSourceID(matching: normalizedURL) {
|
||||||
|
updateSource(existingSourceID) { source in
|
||||||
|
if source.bookmarkData == nil {
|
||||||
|
source.bookmarkData = bookmarkData
|
||||||
|
}
|
||||||
|
source.accessDescriptor = SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: candidate.providerID,
|
||||||
|
kind: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
source.providerID = candidate.providerID
|
||||||
|
source.edition = candidate.edition
|
||||||
|
source.displayName = candidate.displayName
|
||||||
|
source.capabilities = source.origin.defaultCapabilities
|
||||||
|
}
|
||||||
|
removeSourceCandidates(matching: candidate, sourceID: existingSourceID)
|
||||||
|
startScan(for: existingSourceID, mode: .fullScan)
|
||||||
|
return existingSourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = MinecraftSource(
|
||||||
|
folderURL: normalizedURL,
|
||||||
|
bookmarkData: bookmarkData,
|
||||||
|
accessDescriptor: SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: candidate.providerID,
|
||||||
|
kind: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source.providerID = candidate.providerID
|
||||||
|
source.edition = candidate.edition
|
||||||
|
source.displayName = candidate.displayName
|
||||||
|
|
||||||
|
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
|
removeSourceCandidates(matching: candidate, sourceID: sourceID)
|
||||||
|
return sourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
|
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
|
||||||
if sources.contains(where: { $0.id == source.id }) {
|
if let existingSourceID = existingSourceID(matching: source.id) {
|
||||||
updateSource(source.id) { existingSource in
|
updateSource(existingSourceID) { existingSource in
|
||||||
existingSource.origin = source.origin
|
existingSource.origin = source.origin
|
||||||
existingSource.accessDescriptor = source.accessDescriptor
|
existingSource.accessDescriptor = source.accessDescriptor
|
||||||
|
existingSource.providerID = source.accessDescriptor.accessorIdentifier
|
||||||
|
existingSource.edition = source.edition
|
||||||
|
existingSource.accessStatus = source.origin.defaultAccessStatus(displayName: source.displayName)
|
||||||
existingSource.availability = source.availability
|
existingSource.availability = source.availability
|
||||||
existingSource.capabilities = source.capabilities
|
existingSource.capabilities = source.capabilities
|
||||||
if existingSource.bookmarkData == nil {
|
if existingSource.bookmarkData == nil {
|
||||||
@ -184,19 +298,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
} else {
|
} else {
|
||||||
var resolvedSource = source
|
var resolvedSource = source
|
||||||
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
||||||
|
resolvedSource.providerID = resolvedSource.accessDescriptor.accessorIdentifier
|
||||||
|
resolvedSource.edition = source.edition
|
||||||
|
resolvedSource.accessStatus = resolvedSource.origin.defaultAccessStatus(displayName: resolvedSource.displayName)
|
||||||
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
|
resolvedSource.capabilities = resolvedSource.origin.defaultCapabilities
|
||||||
sources.append(resolvedSource)
|
sources.append(resolvedSource)
|
||||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldPersist {
|
if shouldPersist {
|
||||||
persistSourceIfAvailable(withID: source.id)
|
persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id)
|
||||||
}
|
}
|
||||||
if shouldScan {
|
if shouldScan {
|
||||||
startScan(for: source.id, mode: .fullScan)
|
startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.id
|
return existingSourceID(matching: source.id) ?? source.id
|
||||||
}
|
}
|
||||||
|
|
||||||
func source(withID sourceID: URL) -> MinecraftSource? {
|
func source(withID sourceID: URL) -> MinecraftSource? {
|
||||||
@ -211,6 +328,42 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
startScan(for: sourceID, mode: .fullScan)
|
startScan(for: sourceID, mode: .fullScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discoverSourceCandidates() {
|
||||||
|
candidateDiscoveryTask?.cancel()
|
||||||
|
isDiscoveringSourceCandidates = true
|
||||||
|
sourceCandidates.removeAll { candidateAlreadyAdded($0) }
|
||||||
|
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer {
|
||||||
|
self.isDiscoveringSourceCandidates = false
|
||||||
|
self.candidateDiscoveryTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
for try await event in self.sourceAccessMethod.discoverSourceCandidates() {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .candidate(let candidate):
|
||||||
|
self.recordSourceCandidate(candidate)
|
||||||
|
case .stageUpdated, .warning:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateDiscoveryTask = task
|
||||||
|
}
|
||||||
|
|
||||||
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
|
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
|
||||||
try await sourceAccessMethod.listItemContents(for: item, in: source)
|
try await sourceAccessMethod.listItemContents(for: item, in: source)
|
||||||
}
|
}
|
||||||
@ -251,143 +404,6 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func installPackages(at packageURLs: [URL], into sourceID: URL) async throws -> [InstalledContentItem] {
|
|
||||||
guard
|
|
||||||
let destinationSource = source(withID: sourceID),
|
|
||||||
destinationSource.availability == .available,
|
|
||||||
destinationSource.capabilities.canInstallItems
|
|
||||||
else {
|
|
||||||
throw SourceAccessError.accessFailed(reason: "This source is not currently available for installation.")
|
|
||||||
}
|
|
||||||
guard !packageURLs.isEmpty else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
scanTasks[sourceID]?.cancel()
|
|
||||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
|
||||||
completedCount: 0,
|
|
||||||
totalCount: packageURLs.count,
|
|
||||||
status: "Preparing import..."
|
|
||||||
)
|
|
||||||
|
|
||||||
var plans: [InstallationPlan] = []
|
|
||||||
var installedItems: [InstalledContentItem] = []
|
|
||||||
var totalPayloadCount = 0
|
|
||||||
do {
|
|
||||||
for packageURL in packageURLs {
|
|
||||||
let plan = try await Task.detached(priority: .userInitiated) {
|
|
||||||
let accessedSecurityScope = packageURL.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if accessedSecurityScope {
|
|
||||||
packageURL.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return try MinecraftPackageInstaller.preparePackage(at: packageURL)
|
|
||||||
}.value
|
|
||||||
plans.append(plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloads = plans.flatMap(\.payloads)
|
|
||||||
totalPayloadCount = payloads.count
|
|
||||||
try validatePackConflicts(payloads, destinationSource: destinationSource)
|
|
||||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
|
||||||
completedCount: 0,
|
|
||||||
totalCount: payloads.count,
|
|
||||||
status: payloads.count == 1 ? "Installing item..." : "Installing \(payloads.count) items..."
|
|
||||||
)
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
guard let currentDestination = source(withID: sourceID) else {
|
|
||||||
throw SourceAccessError.accessFailed(reason: "The destination source was removed during installation.")
|
|
||||||
}
|
|
||||||
let installedItem = try await sourceAccessMethod.install(payload, in: currentDestination)
|
|
||||||
installedItems.append(installedItem)
|
|
||||||
installationStateBySourceID[sourceID] = SourceInstallationState(
|
|
||||||
completedCount: installedItems.count,
|
|
||||||
totalCount: payloads.count,
|
|
||||||
status: "Installed \(installedItems.count) of \(payloads.count)..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
plans.forEach { $0.cleanup() }
|
|
||||||
installationStateBySourceID[sourceID] = nil
|
|
||||||
startScan(for: sourceID, mode: .fullScan)
|
|
||||||
return installedItems
|
|
||||||
} catch {
|
|
||||||
plans.forEach { $0.cleanup() }
|
|
||||||
installationStateBySourceID[sourceID] = nil
|
|
||||||
if !installedItems.isEmpty {
|
|
||||||
startScan(for: sourceID, mode: .fullScan)
|
|
||||||
throw MinecraftPackageInstaller.InstallError.partialInstallation(
|
|
||||||
completed: installedItems.count,
|
|
||||||
total: totalPayloadCount,
|
|
||||||
reason: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyItem(
|
|
||||||
_ item: MinecraftContentItem,
|
|
||||||
from sourceID: URL,
|
|
||||||
into destinationSourceID: URL
|
|
||||||
) async throws -> [InstalledContentItem] {
|
|
||||||
guard let source = source(withID: sourceID) else {
|
|
||||||
throw SourceAccessError.accessFailed(reason: "The source item is no longer available.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let representation = try await externalRepresentation(
|
|
||||||
for: item,
|
|
||||||
in: source,
|
|
||||||
preferredKind: .portablePackage
|
|
||||||
)
|
|
||||||
defer {
|
|
||||||
if representation.isTemporary {
|
|
||||||
try? FileManager.default.removeItem(at: representation.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return try await installPackages(at: [representation.url], into: destinationSourceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validatePackConflicts(
|
|
||||||
_ payloads: [InstallationPayload],
|
|
||||||
destinationSource: MinecraftSource
|
|
||||||
) throws {
|
|
||||||
var existingIdentities = Set(
|
|
||||||
destinationSource.rawItems.compactMap { item -> String? in
|
|
||||||
guard
|
|
||||||
item.contentType == .behaviorPack ||
|
|
||||||
item.contentType == .resourcePack ||
|
|
||||||
item.contentType == .skinPack,
|
|
||||||
let uuid = item.packUUID?.lowercased()
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "\(item.contentType.rawValue)::\(uuid)"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
guard
|
|
||||||
payload.contentType == .behaviorPack ||
|
|
||||||
payload.contentType == .resourcePack ||
|
|
||||||
payload.contentType == .skinPack,
|
|
||||||
let uuid = payload.packUUID?.lowercased()
|
|
||||||
else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let identity = "\(payload.contentType.rawValue)::\(uuid)"
|
|
||||||
guard !existingIdentities.contains(identity) else {
|
|
||||||
throw MinecraftPackageInstaller.InstallError.duplicatePack(
|
|
||||||
name: payload.displayName,
|
|
||||||
version: payload.packVersion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
existingIdentities.insert(identity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeSource(withID sourceID: URL) {
|
func removeSource(withID sourceID: URL) {
|
||||||
let removedSource = source(withID: sourceID)
|
let removedSource = source(withID: sourceID)
|
||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
@ -458,6 +474,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
source.worldPackRelationships = index.worldPackRelationships
|
source.worldPackRelationships = index.worldPackRelationships
|
||||||
source.displayItems = index.displayItems
|
source.displayItems = index.displayItems
|
||||||
source.displayItemCountsByType = index.displayItemCountsByType
|
source.displayItemCountsByType = index.displayItemCountsByType
|
||||||
|
source.displayItemCountsByKind = index.displayItemCountsByKind
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,6 +517,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.displayItems = snapshot.displayItems
|
source.displayItems = snapshot.displayItems
|
||||||
source.displayItemCountsByType = snapshot.displayItemCountsByType
|
source.displayItemCountsByType = snapshot.displayItemCountsByType
|
||||||
|
source.displayItemCountsByKind = snapshot.displayItemCountsByKind
|
||||||
source.rawItems = snapshot.rawItems
|
source.rawItems = snapshot.rawItems
|
||||||
source.logicalPacks = snapshot.logicalPacks
|
source.logicalPacks = snapshot.logicalPacks
|
||||||
source.logicalWorlds = snapshot.logicalWorlds
|
source.logicalWorlds = snapshot.logicalWorlds
|
||||||
@ -570,8 +588,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 {
|
||||||
@ -661,6 +684,54 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sourceIDByItemID = itemIndex
|
sourceIDByItemID = itemIndex
|
||||||
|
sourceCandidates.removeAll { candidateAlreadyAdded($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordSourceCandidate(_ candidate: SourceCandidate) {
|
||||||
|
guard !candidateAlreadyAdded(candidate) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existingIndex = sourceCandidates.firstIndex(where: { $0.id == candidate.id }) {
|
||||||
|
if candidate.confidence > sourceCandidates[existingIndex].confidence {
|
||||||
|
sourceCandidates[existingIndex] = candidate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sourceCandidates.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceCandidates.sort {
|
||||||
|
if $0.confidence != $1.confidence {
|
||||||
|
return $0.confidence > $1.confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool {
|
||||||
|
sources.contains { source in
|
||||||
|
sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL)
|
||||||
|
|| sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingSourceID(matching url: URL) -> URL? {
|
||||||
|
let identity = sourceIdentityKey(for: url)
|
||||||
|
return sources.first { source in
|
||||||
|
sourceIdentityKey(for: source.id) == identity
|
||||||
|
|| sourceIdentityKey(for: source.folderURL) == identity
|
||||||
|
}?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSourceCandidates(matching candidate: SourceCandidate, sourceID: URL) {
|
||||||
|
let candidateIdentity = sourceIdentityKey(for: candidate.sourceRootURL)
|
||||||
|
let sourceIdentity = sourceIdentityKey(for: sourceID)
|
||||||
|
sourceCandidates.removeAll {
|
||||||
|
$0.id == candidate.id
|
||||||
|
|| sourceIdentityKey(for: $0.sourceRootURL) == candidateIdentity
|
||||||
|
|| sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentity
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@ -120,8 +120,7 @@ enum SourcePresentation {
|
|||||||
case .discovering, .metadata, .previews:
|
case .discovering, .metadata, .previews:
|
||||||
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
|
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
|
||||||
case .sizing:
|
case .sizing:
|
||||||
let remainingCount = max(source.indexedItemCount - source.sizeLoadedCount, 0)
|
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
|
||||||
return "Calculating sizes for \(remainingCount) of \(source.indexedItemCount) items..."
|
|
||||||
case .completed:
|
case .completed:
|
||||||
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
|
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
|
||||||
case .idle:
|
case .idle:
|
||||||
|
|||||||
@ -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
|
||||||
@ -56,6 +58,9 @@ enum SourceRestoration {
|
|||||||
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||||
counts[item.contentType, default: 0] += 1
|
counts[item.contentType, default: 0] += 1
|
||||||
}
|
}
|
||||||
|
source.displayItemCountsByKind = items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
||||||
|
counts[item.contentKind, default: 0] += 1
|
||||||
|
}
|
||||||
source.indexedItemCount = items.count
|
source.indexedItemCount = items.count
|
||||||
source.indexedDetailCount = items.filter(\.metadataLoaded).count
|
source.indexedDetailCount = items.filter(\.metadataLoaded).count
|
||||||
source.previewLoadedCount = items.filter(\.previewLoaded).count
|
source.previewLoadedCount = items.filter(\.previewLoaded).count
|
||||||
@ -101,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
|
||||||
@ -130,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)
|
||||||
}
|
}
|
||||||
@ -149,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
|
||||||
@ -164,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
|
||||||
@ -188,29 +194,38 @@ 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]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let currentCollectionsByName = Dictionary(
|
let currentCollectionsByName = Dictionary(grouping: currentCollections, by: \.folderName)
|
||||||
uniqueKeysWithValues: currentCollections.map { ($0.folderName, $0) }
|
.mapValues { $0.map(\.fingerprint).sorted() }
|
||||||
)
|
let persistedCollectionsByName = Dictionary(grouping: persistedCollections, by: \.folderName)
|
||||||
let persistedCollectionsByName = Dictionary(
|
.mapValues { $0.map(\.fingerprint).sorted() }
|
||||||
uniqueKeysWithValues: persistedCollections.map { ($0.folderName, $0) }
|
|
||||||
)
|
|
||||||
|
|
||||||
if currentCollectionsByName.count != persistedCollectionsByName.count {
|
if currentCollectionsByName.count != persistedCollectionsByName.count {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for (folderName, persistedCollection) in persistedCollectionsByName {
|
for (folderName, persistedFingerprints) in persistedCollectionsByName {
|
||||||
guard let currentCollection = currentCollectionsByName[folderName],
|
guard let currentFingerprints = currentCollectionsByName[folderName],
|
||||||
currentCollection.fingerprint == persistedCollection.fingerprint else {
|
currentFingerprints == persistedFingerprints else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ struct SourceContentIndex {
|
|||||||
let worldPackRelationships: [WorldPackRelationship]
|
let worldPackRelationships: [WorldPackRelationship]
|
||||||
let displayItems: [MinecraftContentItem]
|
let displayItems: [MinecraftContentItem]
|
||||||
let displayItemCountsByType: [MinecraftContentType: Int]
|
let displayItemCountsByType: [MinecraftContentType: Int]
|
||||||
|
let displayItemCountsByKind: [MinecraftContentKind: Int]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SourceContentIndexer {
|
enum SourceContentIndexer {
|
||||||
@ -171,7 +172,8 @@ enum SourceContentIndexer {
|
|||||||
packInstances: sortedPackInstances,
|
packInstances: sortedPackInstances,
|
||||||
worldPackRelationships: worldRelationships,
|
worldPackRelationships: worldRelationships,
|
||||||
displayItems: displayItems,
|
displayItems: displayItems,
|
||||||
displayItemCountsByType: displayItemCounts(for: displayItems)
|
displayItemCountsByType: displayItemCounts(for: displayItems),
|
||||||
|
displayItemCountsByKind: displayItemKindCounts(for: displayItems)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +221,12 @@ enum SourceContentIndexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func displayItemKindCounts(for items: [MinecraftContentItem]) -> [MinecraftContentKind: Int] {
|
||||||
|
items.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
||||||
|
counts[item.contentKind, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
||||||
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
||||||
let existingEmbedded = isEmbeddedWorldPack(existing)
|
let existingEmbedded = isEmbeddedWorldPack(existing)
|
||||||
|
|||||||
@ -50,9 +50,10 @@ enum SourceScanExecutor {
|
|||||||
host.updateSource(sourceID) { source in
|
host.updateSource(sourceID) { source in
|
||||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
}
|
}
|
||||||
let currentAvailability = await sourceAccessMethod.availability(for: source)
|
let currentAccessStatus = await sourceAccessMethod.accessStatus(for: source)
|
||||||
host.updateSource(sourceID) { source in
|
host.updateSource(sourceID) { source in
|
||||||
source.availability = currentAvailability
|
source.accessStatus = currentAccessStatus
|
||||||
|
source.availability = currentAccessStatus.availability
|
||||||
}
|
}
|
||||||
|
|
||||||
let scanContextURL = source.folderURL
|
let scanContextURL = source.folderURL
|
||||||
@ -89,22 +90,7 @@ enum SourceScanExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
|
let providerEventStream = sourceAccessMethod.scanEvents(for: source, mode: mode)
|
||||||
let discoveryTask = Task.detached(priority: .userInitiated) {
|
|
||||||
do {
|
|
||||||
try await sourceAccessMethod.discoverItems(for: source, mode: mode) { item in
|
|
||||||
continuation.yield(item)
|
|
||||||
}
|
|
||||||
continuation.finish()
|
|
||||||
} catch {
|
|
||||||
continuation.finish(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.onTermination = { @Sendable _ in
|
|
||||||
discoveryTask.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) })
|
let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) })
|
||||||
let previousSnapshotByItemID = Dictionary(
|
let previousSnapshotByItemID = Dictionary(
|
||||||
@ -116,35 +102,61 @@ enum SourceScanExecutor {
|
|||||||
var discoveredCollectionNames = Set<String>()
|
var discoveredCollectionNames = Set<String>()
|
||||||
let discoveryStartTime = Date()
|
let discoveryStartTime = Date()
|
||||||
|
|
||||||
for try await item in discoveryStream {
|
for try await event in providerEventStream {
|
||||||
guard !Task.isCancelled else {
|
guard !Task.isCancelled else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
discoveredCount += 1
|
switch event {
|
||||||
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
|
case .accessStatusChanged(let accessStatus):
|
||||||
let itemForIndex: MinecraftContentItem
|
host.updateSource(sourceID) { source in
|
||||||
if shouldReconcileFromCache,
|
source.accessStatus = accessStatus
|
||||||
let cachedItem = previousItemsByID[item.id],
|
source.availability = accessStatus.availability
|
||||||
SourceScanPolicy.shouldReuseCachedItem(
|
}
|
||||||
cachedItem,
|
continue
|
||||||
forDiscoveredItem: item,
|
case .stageUpdated(let stage):
|
||||||
source: source,
|
host.updateSource(sourceID) { source in
|
||||||
previousSnapshot: previousSnapshotByItemID[item.id]
|
source.scanStatus = stage.detail ?? stage.title
|
||||||
) {
|
}
|
||||||
itemForIndex = cachedItem
|
continue
|
||||||
} else {
|
case .warning(let warning):
|
||||||
itemForIndex = item
|
host.updateSource(sourceID) { source in
|
||||||
}
|
source.scanDiagnostic = warning.detail ?? warning.message
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case .inspected(let inspectedItem):
|
||||||
|
if let snapshot = await index.applyEnrichedItem(inspectedItem) {
|
||||||
|
await MainActor.run {
|
||||||
|
host.applySnapshot(snapshot, to: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case .discovered(let item):
|
||||||
|
discoveredCount += 1
|
||||||
|
discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent)
|
||||||
|
let itemForIndex: MinecraftContentItem
|
||||||
|
if shouldReconcileFromCache,
|
||||||
|
let cachedItem = previousItemsByID[item.id],
|
||||||
|
SourceScanPolicy.shouldReuseCachedItem(
|
||||||
|
cachedItem,
|
||||||
|
forDiscoveredItem: item,
|
||||||
|
source: source,
|
||||||
|
previousSnapshot: previousSnapshotByItemID[item.id]
|
||||||
|
) {
|
||||||
|
itemForIndex = cachedItem
|
||||||
|
} else {
|
||||||
|
itemForIndex = item
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot = await index.addDiscoveredItem(
|
if let snapshot = await index.addDiscoveredItem(
|
||||||
itemForIndex,
|
itemForIndex,
|
||||||
discoveredCount: discoveredCount
|
discoveredCount: discoveredCount
|
||||||
) {
|
) {
|
||||||
host.applySnapshot(snapshot, to: sourceID)
|
host.applySnapshot(snapshot, to: sourceID)
|
||||||
}
|
}
|
||||||
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
|
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
|
||||||
await enrichmentQueue.enqueue(item)
|
await enrichmentQueue.enqueue(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,6 +239,23 @@ enum SourceScanExecutor {
|
|||||||
}
|
}
|
||||||
host.persistSourceIfAvailable(withID: sourceID)
|
host.persistSourceIfAvailable(withID: sourceID)
|
||||||
|
|
||||||
|
if source.origin.kind == .connectedDevice {
|
||||||
|
try await finishConnectedDeviceScan(
|
||||||
|
sourceID: sourceID,
|
||||||
|
source: source,
|
||||||
|
host: host,
|
||||||
|
sourceAccessMethod: sourceAccessMethod,
|
||||||
|
notificationService: notificationService,
|
||||||
|
index: index,
|
||||||
|
discoveredCount: discoveredCount,
|
||||||
|
scanStartTime: scanStartTime,
|
||||||
|
scanContextURL: scanContextURL,
|
||||||
|
performanceContext: performanceContext,
|
||||||
|
minimumVisibleScanDuration: minimumVisibleScanDuration
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let sizeQueue = EnrichmentWorkQueue()
|
let sizeQueue = EnrichmentWorkQueue()
|
||||||
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
|
sizeWorkerTasks = (0..<resolvedSizeWorkerCount).map { _ in
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
@ -305,6 +334,52 @@ enum SourceScanExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func finishConnectedDeviceScan(
|
||||||
|
sourceID: URL,
|
||||||
|
source: MinecraftSource,
|
||||||
|
host: SourceScanSessionHosting,
|
||||||
|
sourceAccessMethod: SourceAccessMethod,
|
||||||
|
notificationService: ScanNotificationServicing,
|
||||||
|
index: SourceIndexActor,
|
||||||
|
discoveredCount: Int,
|
||||||
|
scanStartTime: Date,
|
||||||
|
scanContextURL: URL,
|
||||||
|
performanceContext: String,
|
||||||
|
minimumVisibleScanDuration: TimeInterval
|
||||||
|
) async throws {
|
||||||
|
let sizeStageStartTime = Date()
|
||||||
|
let sizeSeedItems = await index.currentItems()
|
||||||
|
let sizedItems = await sourceAccessMethod.loadSizeAssets(
|
||||||
|
for: sizeSeedItems.filter { !$0.sizeLoaded },
|
||||||
|
in: source
|
||||||
|
)
|
||||||
|
for sizedItem in sizedItems {
|
||||||
|
if let snapshot = await index.applySizedItem(sizedItem) {
|
||||||
|
host.applySnapshot(snapshot, to: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host.logScanStage(
|
||||||
|
"Size",
|
||||||
|
elapsed: Date().timeIntervalSince(sizeStageStartTime),
|
||||||
|
context: performanceContext,
|
||||||
|
itemCount: discoveredCount
|
||||||
|
)
|
||||||
|
|
||||||
|
try await finishScan(
|
||||||
|
sourceID: sourceID,
|
||||||
|
source: source,
|
||||||
|
host: host,
|
||||||
|
notificationService: notificationService,
|
||||||
|
index: index,
|
||||||
|
discoveredCount: discoveredCount,
|
||||||
|
scanStartTime: scanStartTime,
|
||||||
|
scanContextURL: scanContextURL,
|
||||||
|
performanceContext: performanceContext,
|
||||||
|
minimumVisibleScanDuration: minimumVisibleScanDuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static func finishScan(
|
private static func finishScan(
|
||||||
sourceID: URL,
|
sourceID: URL,
|
||||||
source: MinecraftSource,
|
source: MinecraftSource,
|
||||||
@ -401,6 +476,7 @@ private actor EnrichmentWorkQueue {
|
|||||||
struct SourceIndexSnapshot {
|
struct SourceIndexSnapshot {
|
||||||
let displayItems: [MinecraftContentItem]
|
let displayItems: [MinecraftContentItem]
|
||||||
let displayItemCountsByType: [MinecraftContentType: Int]
|
let displayItemCountsByType: [MinecraftContentType: Int]
|
||||||
|
let displayItemCountsByKind: [MinecraftContentKind: Int]
|
||||||
let rawItems: [MinecraftContentItem]
|
let rawItems: [MinecraftContentItem]
|
||||||
let logicalPacks: [LogicalPack]
|
let logicalPacks: [LogicalPack]
|
||||||
let logicalWorlds: [LogicalWorld]
|
let logicalWorlds: [LogicalWorld]
|
||||||
@ -568,6 +644,9 @@ private actor SourceIndexActor {
|
|||||||
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||||
counts[item.contentType, default: 0] += 1
|
counts[item.contentType, default: 0] += 1
|
||||||
}
|
}
|
||||||
|
let displayItemCountsByKind = dedupedDisplayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
||||||
|
counts[item.contentKind, default: 0] += 1
|
||||||
|
}
|
||||||
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
|
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
|
||||||
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
|
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
|
||||||
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
|
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
|
||||||
@ -589,6 +668,7 @@ private actor SourceIndexActor {
|
|||||||
return SourceIndexSnapshot(
|
return SourceIndexSnapshot(
|
||||||
displayItems: dedupedDisplayItems,
|
displayItems: dedupedDisplayItems,
|
||||||
displayItemCountsByType: displayItemCountsByType,
|
displayItemCountsByType: displayItemCountsByType,
|
||||||
|
displayItemCountsByKind: displayItemCountsByKind,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
logicalPacks: logicalPacks,
|
logicalPacks: logicalPacks,
|
||||||
logicalWorlds: [],
|
logicalWorlds: [],
|
||||||
@ -617,6 +697,7 @@ private actor SourceIndexActor {
|
|||||||
return SourceIndexSnapshot(
|
return SourceIndexSnapshot(
|
||||||
displayItems: dedupedDisplayItems,
|
displayItems: dedupedDisplayItems,
|
||||||
displayItemCountsByType: displayItemCountsByType,
|
displayItemCountsByType: displayItemCountsByType,
|
||||||
|
displayItemCountsByKind: displayItemCountsByKind,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
logicalPacks: logicalPacks,
|
logicalPacks: logicalPacks,
|
||||||
logicalWorlds: [],
|
logicalWorlds: [],
|
||||||
@ -649,6 +730,7 @@ private actor SourceIndexActor {
|
|||||||
return SourceIndexSnapshot(
|
return SourceIndexSnapshot(
|
||||||
displayItems: dedupedDisplayItems,
|
displayItems: dedupedDisplayItems,
|
||||||
displayItemCountsByType: displayItemCountsByType,
|
displayItemCountsByType: displayItemCountsByType,
|
||||||
|
displayItemCountsByKind: displayItemCountsByKind,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
logicalPacks: logicalPacks,
|
logicalPacks: logicalPacks,
|
||||||
logicalWorlds: [],
|
logicalWorlds: [],
|
||||||
@ -750,8 +832,7 @@ private actor SourceIndexActor {
|
|||||||
} else if sizeLoadedCount == 0 {
|
} else if sizeLoadedCount == 0 {
|
||||||
scanStatus = "Preparing size calculations..."
|
scanStatus = "Preparing size calculations..."
|
||||||
} else {
|
} else {
|
||||||
let remainingCount = max(indexedItemCount - sizeLoadedCount, 0)
|
scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
|
||||||
scanStatus = "Calculating sizes for \(remainingCount) of \(indexedItemCount) items..."
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scanStatus = indexedItemCount == 0
|
scanStatus = indexedItemCount == 0
|
||||||
@ -762,6 +843,7 @@ private actor SourceIndexActor {
|
|||||||
return SourceIndexSnapshot(
|
return SourceIndexSnapshot(
|
||||||
displayItems: dedupedDisplayItems,
|
displayItems: dedupedDisplayItems,
|
||||||
displayItemCountsByType: displayItemCountsByType,
|
displayItemCountsByType: displayItemCountsByType,
|
||||||
|
displayItemCountsByKind: displayItemCountsByKind,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
logicalPacks: logicalPacks,
|
logicalPacks: logicalPacks,
|
||||||
logicalWorlds: logicalWorlds,
|
logicalWorlds: logicalWorlds,
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import Foundation
|
|||||||
enum SourceScanPolicy {
|
enum SourceScanPolicy {
|
||||||
static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
static func initialStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
||||||
switch (source.origin, mode) {
|
switch (source.origin, mode) {
|
||||||
case (.localFolder, .fullScan):
|
case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan):
|
||||||
return "Preparing folder scan..."
|
return "Preparing folder scan..."
|
||||||
case (.localFolder, .reconcile):
|
case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile):
|
||||||
return "Preparing cached library refresh..."
|
return "Preparing cached library refresh..."
|
||||||
case (.connectedDevice, .fullScan):
|
case (.connectedDevice, .fullScan):
|
||||||
return "Connecting to device and discovering Minecraft items..."
|
return "Connecting to device and discovering Minecraft items..."
|
||||||
@ -19,9 +19,9 @@ enum SourceScanPolicy {
|
|||||||
|
|
||||||
static func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
static func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
|
||||||
switch (source.origin, mode) {
|
switch (source.origin, mode) {
|
||||||
case (.localFolder, .fullScan):
|
case (.localFolder, .fullScan), (.javaLocalFolder, .fullScan):
|
||||||
return "Scanning Minecraft library..."
|
return "Scanning Minecraft library..."
|
||||||
case (.localFolder, .reconcile):
|
case (.localFolder, .reconcile), (.javaLocalFolder, .reconcile):
|
||||||
return "Reconciling cached library..."
|
return "Reconciling cached library..."
|
||||||
case (.connectedDevice, .fullScan):
|
case (.connectedDevice, .fullScan):
|
||||||
return "Scanning Minecraft library on device..."
|
return "Scanning Minecraft library on device..."
|
||||||
@ -32,7 +32,7 @@ enum SourceScanPolicy {
|
|||||||
|
|
||||||
static func performanceContext(for source: MinecraftSource) -> String {
|
static func performanceContext(for source: MinecraftSource) -> String {
|
||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return "source=\(source.displayName) kind=local"
|
return "source=\(source.displayName) kind=local"
|
||||||
case .connectedDevice(let device, let container):
|
case .connectedDevice(let device, let container):
|
||||||
let transport = device.connection == .usb ? "usb" : "network"
|
let transport = device.connection == .usb ? "usb" : "network"
|
||||||
@ -121,7 +121,13 @@ enum SourceScanPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func buildSnapshot(for source: MinecraftSource, scanRootURL: URL) -> SourceSnapshot {
|
static func buildSnapshot(for source: MinecraftSource, scanRootURL: URL) -> SourceSnapshot {
|
||||||
let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
|
let collectionSnapshots: [CollectionSnapshot]
|
||||||
|
switch source.edition {
|
||||||
|
case .bedrock:
|
||||||
|
collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
|
||||||
|
case .java:
|
||||||
|
collectionSnapshots = JavaContentScanner.collectionSnapshots(in: scanRootURL)
|
||||||
|
}
|
||||||
|
|
||||||
let itemSnapshots = source.rawItems.map { item in
|
let itemSnapshots = source.rawItems.map { item in
|
||||||
ItemSnapshot(
|
ItemSnapshot(
|
||||||
@ -153,6 +159,7 @@ enum SourceScanRecovery {
|
|||||||
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
|
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
|
||||||
source.displayItems = previousSource.displayItems
|
source.displayItems = previousSource.displayItems
|
||||||
source.displayItemCountsByType = previousSource.displayItemCountsByType
|
source.displayItemCountsByType = previousSource.displayItemCountsByType
|
||||||
|
source.displayItemCountsByKind = previousSource.displayItemCountsByKind
|
||||||
source.rawItems = previousSource.rawItems
|
source.rawItems = previousSource.rawItems
|
||||||
source.logicalPacks = previousSource.logicalPacks
|
source.logicalPacks = previousSource.logicalPacks
|
||||||
source.logicalWorlds = previousSource.logicalWorlds
|
source.logicalWorlds = previousSource.logicalWorlds
|
||||||
|
|||||||
@ -177,37 +177,6 @@ enum AppleMobileDeviceAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func installDirectory(
|
|
||||||
deviceIdentifier: String,
|
|
||||||
bundleIdentifier: String,
|
|
||||||
minecraftRootRelativePath: String,
|
|
||||||
collectionFolderName: String,
|
|
||||||
preferredDestinationName: String,
|
|
||||||
sourceDirectoryURL: URL
|
|
||||||
) async throws -> String {
|
|
||||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
||||||
try await Task.detached(priority: .userInitiated) {
|
|
||||||
var error: NSError?
|
|
||||||
guard let installedName = WMMInstallLocalDirectoryInConnectedDeviceApp(
|
|
||||||
deviceIdentifier,
|
|
||||||
bundleIdentifier,
|
|
||||||
minecraftRootRelativePath,
|
|
||||||
collectionFolderName,
|
|
||||||
preferredDestinationName,
|
|
||||||
sourceDirectoryURL,
|
|
||||||
&error
|
|
||||||
) else {
|
|
||||||
throw error ?? NSError(
|
|
||||||
domain: "AppleMobileDeviceAccess",
|
|
||||||
code: 16,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice content installation failed."]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return installedName
|
|
||||||
}.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
||||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
|||||||
@ -98,15 +98,4 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
|||||||
NSError **error
|
NSError **error
|
||||||
);
|
);
|
||||||
|
|
||||||
FOUNDATION_EXPORT NSString * _Nullable
|
|
||||||
WMMInstallLocalDirectoryInConnectedDeviceApp(
|
|
||||||
NSString *deviceIdentifier,
|
|
||||||
NSString *bundleIdentifier,
|
|
||||||
NSString *minecraftRootRelativePath,
|
|
||||||
NSString *collectionFolderName,
|
|
||||||
NSString *preferredDestinationName,
|
|
||||||
NSURL *sourceDirectoryURL,
|
|
||||||
NSError **error
|
|
||||||
);
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@ -122,11 +122,7 @@ typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **val
|
|||||||
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
||||||
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
||||||
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
||||||
typedef int (*AFCFileRefWriteFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, const void *buffer, uint32_t length);
|
|
||||||
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
||||||
typedef int (*AFCDirectoryCreateFn)(AFCConnectionRef connection, const char *path);
|
|
||||||
typedef int (*AFCRenamePathFn)(AFCConnectionRef connection, const char *sourcePath, const char *destinationPath);
|
|
||||||
typedef int (*AFCRemovePathFn)(AFCConnectionRef connection, const char *path);
|
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
void *handle;
|
void *handle;
|
||||||
@ -163,11 +159,7 @@ typedef struct {
|
|||||||
AFCKeyValueCloseFn AFCKeyValueClose;
|
AFCKeyValueCloseFn AFCKeyValueClose;
|
||||||
AFCFileRefOpenFn AFCFileRefOpen;
|
AFCFileRefOpenFn AFCFileRefOpen;
|
||||||
AFCFileRefReadFn AFCFileRefRead;
|
AFCFileRefReadFn AFCFileRefRead;
|
||||||
AFCFileRefWriteFn AFCFileRefWrite;
|
|
||||||
AFCFileRefCloseFn AFCFileRefClose;
|
AFCFileRefCloseFn AFCFileRefClose;
|
||||||
AFCDirectoryCreateFn AFCDirectoryCreate;
|
|
||||||
AFCRenamePathFn AFCRenamePath;
|
|
||||||
AFCRemovePathFn AFCRemovePath;
|
|
||||||
} WMMMobileDeviceFunctions;
|
} WMMMobileDeviceFunctions;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@ -245,11 +237,7 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
|||||||
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
||||||
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
||||||
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
||||||
functions->AFCFileRefWrite = (AFCFileRefWriteFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefWrite");
|
|
||||||
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
||||||
functions->AFCDirectoryCreate = (AFCDirectoryCreateFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryCreate");
|
|
||||||
functions->AFCRenamePath = (AFCRenamePathFn)WMMLoadSymbol(frameworkHandle, "AFCRenamePath");
|
|
||||||
functions->AFCRemovePath = (AFCRemovePathFn)WMMLoadSymbol(frameworkHandle, "AFCRemovePath");
|
|
||||||
|
|
||||||
if (functions->AMDeviceNotificationSubscribe == NULL ||
|
if (functions->AMDeviceNotificationSubscribe == NULL ||
|
||||||
functions->AMDeviceNotificationUnsubscribe == NULL ||
|
functions->AMDeviceNotificationUnsubscribe == NULL ||
|
||||||
@ -1048,163 +1036,6 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
|||||||
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
static BOOL WMMEnsureAFCDirectory(
|
|
||||||
WMMMobileDeviceFunctions *functions,
|
|
||||||
AFCConnectionRef afcConnection,
|
|
||||||
NSString *remotePath,
|
|
||||||
NSError **error
|
|
||||||
) {
|
|
||||||
NSMutableArray<NSString *> *existingEntries = nil;
|
|
||||||
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &existingEntries) == 0) {
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int createStatus = functions->AFCDirectoryCreate(
|
|
||||||
afcConnection,
|
|
||||||
remotePath.fileSystemRepresentation
|
|
||||||
);
|
|
||||||
if (createStatus != 0) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(
|
|
||||||
createStatus,
|
|
||||||
[NSString stringWithFormat:@"AFCDirectoryCreate failed for %@ (%d).", remotePath, createStatus]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL WMMCopyLocalFileToAFCPath(
|
|
||||||
WMMMobileDeviceFunctions *functions,
|
|
||||||
AFCConnectionRef afcConnection,
|
|
||||||
NSURL *localFileURL,
|
|
||||||
NSString *remotePath,
|
|
||||||
NSError **error
|
|
||||||
) {
|
|
||||||
AFCFileDescriptorRef fileDescriptor = NULL;
|
|
||||||
const int openStatus = functions->AFCFileRefOpen(
|
|
||||||
afcConnection,
|
|
||||||
remotePath.fileSystemRepresentation,
|
|
||||||
3,
|
|
||||||
&fileDescriptor
|
|
||||||
);
|
|
||||||
if (openStatus != 0 || fileDescriptor == NULL) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(
|
|
||||||
openStatus,
|
|
||||||
[NSString stringWithFormat:@"AFCFileRefOpen for writing failed for %@ (%d).", remotePath, openStatus]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSFileHandle *handle = [NSFileHandle fileHandleForReadingFromURL:localFileURL error:error];
|
|
||||||
if (handle == nil) {
|
|
||||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL success = YES;
|
|
||||||
while (true) {
|
|
||||||
@autoreleasepool {
|
|
||||||
NSData *chunk = [handle readDataOfLength:64 * 1024];
|
|
||||||
if (chunk.length == 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const int writeStatus = functions->AFCFileRefWrite(
|
|
||||||
afcConnection,
|
|
||||||
fileDescriptor,
|
|
||||||
chunk.bytes,
|
|
||||||
(uint32_t)chunk.length
|
|
||||||
);
|
|
||||||
if (writeStatus != 0) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(
|
|
||||||
writeStatus,
|
|
||||||
[NSString stringWithFormat:@"AFCFileRefWrite failed for %@ (%d).", remotePath, writeStatus]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = NO;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[handle closeFile];
|
|
||||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL WMMCopyLocalTreeToAFCPath(
|
|
||||||
WMMMobileDeviceFunctions *functions,
|
|
||||||
AFCConnectionRef afcConnection,
|
|
||||||
NSURL *localURL,
|
|
||||||
NSString *remotePath,
|
|
||||||
NSError **error
|
|
||||||
) {
|
|
||||||
NSNumber *isDirectory = nil;
|
|
||||||
if (![localURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:error]) {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDirectory.boolValue) {
|
|
||||||
return WMMCopyLocalFileToAFCPath(functions, afcConnection, localURL, remotePath, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!WMMEnsureAFCDirectory(functions, afcConnection, remotePath, error)) {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSArray<NSURL *> *children = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:localURL
|
|
||||||
includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey]
|
|
||||||
options:0
|
|
||||||
error:error];
|
|
||||||
if (children == nil) {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (NSURL *childURL in children) {
|
|
||||||
NSNumber *isSymbolicLink = nil;
|
|
||||||
if (![childURL getResourceValue:&isSymbolicLink forKey:NSURLIsSymbolicLinkKey error:error]) {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
if (isSymbolicLink.boolValue) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(17, [NSString stringWithFormat:@"Symbolic links cannot be installed: %@", childURL.path]);
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *childRemotePath = [remotePath stringByAppendingPathComponent:childURL.lastPathComponent];
|
|
||||||
if (!WMMCopyLocalTreeToAFCPath(functions, afcConnection, childURL, childRemotePath, error)) {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL WMMRemoveAFCTree(
|
|
||||||
WMMMobileDeviceFunctions *functions,
|
|
||||||
AFCConnectionRef afcConnection,
|
|
||||||
NSString *remotePath
|
|
||||||
) {
|
|
||||||
NSMutableArray<NSString *> *entries = nil;
|
|
||||||
if (WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries) == 0) {
|
|
||||||
for (NSString *entry in entries) {
|
|
||||||
if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
WMMRemoveAFCTree(
|
|
||||||
functions,
|
|
||||||
afcConnection,
|
|
||||||
[remotePath stringByAppendingPathComponent:entry]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return functions->AFCRemovePath(afcConnection, remotePath.fileSystemRepresentation) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
||||||
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
||||||
if (![normalizedPath hasPrefix:@"/"]) {
|
if (![normalizedPath hasPrefix:@"/"]) {
|
||||||
@ -2550,122 +2381,3 @@ WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString * _Nullable
|
|
||||||
WMMInstallLocalDirectoryInConnectedDeviceApp(
|
|
||||||
NSString *deviceIdentifier,
|
|
||||||
NSString *bundleIdentifier,
|
|
||||||
NSString *minecraftRootRelativePath,
|
|
||||||
NSString *collectionFolderName,
|
|
||||||
NSString *preferredDestinationName,
|
|
||||||
NSURL *sourceDirectoryURL,
|
|
||||||
NSError **error
|
|
||||||
) {
|
|
||||||
if (bundleIdentifier.length == 0 ||
|
|
||||||
minecraftRootRelativePath.length == 0 ||
|
|
||||||
collectionFolderName.length == 0 ||
|
|
||||||
preferredDestinationName.length == 0 ||
|
|
||||||
sourceDirectoryURL == nil ||
|
|
||||||
!sourceDirectoryURL.isFileURL) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(16, @"A device, Minecraft path, collection, destination name, and local source directory are required.");
|
|
||||||
}
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
WMMMobileDeviceFunctions functions;
|
|
||||||
if (!WMMLoadFunctions(&functions, error)) {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
if (functions.AFCFileRefWrite == NULL ||
|
|
||||||
functions.AFCDirectoryCreate == NULL ||
|
|
||||||
functions.AFCRenamePath == NULL ||
|
|
||||||
functions.AFCRemovePath == NULL) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(17, @"This version of MobileDevice.framework does not provide the required AFC write operations.");
|
|
||||||
}
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
|
||||||
if (device == NULL) {
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
|
|
||||||
functions.AMDeviceRelease(device);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
|
||||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
|
||||||
&functions,
|
|
||||||
device,
|
|
||||||
bundleIdentifier,
|
|
||||||
&backingServiceConnection,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
if (afcConnection == NULL) {
|
|
||||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
|
||||||
functions.AMDeviceRelease(device);
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *minecraftRoot = WMMNormalizedAFCPath(minecraftRootRelativePath);
|
|
||||||
NSString *stagingRoot = [minecraftRoot stringByAppendingPathComponent:@".world-manager-staging"];
|
|
||||||
NSString *stagedItemPath = [stagingRoot stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
|
|
||||||
NSString *collectionPath = [minecraftRoot stringByAppendingPathComponent:collectionFolderName];
|
|
||||||
|
|
||||||
BOOL success =
|
|
||||||
WMMEnsureAFCDirectory(&functions, afcConnection, stagingRoot, error) &&
|
|
||||||
WMMEnsureAFCDirectory(&functions, afcConnection, stagedItemPath, error) &&
|
|
||||||
WMMCopyLocalTreeToAFCPath(&functions, afcConnection, sourceDirectoryURL, stagedItemPath, error) &&
|
|
||||||
WMMEnsureAFCDirectory(&functions, afcConnection, collectionPath, error);
|
|
||||||
|
|
||||||
NSString *destinationName = nil;
|
|
||||||
if (success) {
|
|
||||||
NSMutableArray<NSString *> *existingEntries = nil;
|
|
||||||
const int listStatus = WMMReadAFCDirectory(&functions, afcConnection, collectionPath, &existingEntries);
|
|
||||||
if (listStatus != 0) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(
|
|
||||||
listStatus,
|
|
||||||
[NSString stringWithFormat:@"Could not list the destination collection %@ (%d).", collectionPath, listStatus]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = NO;
|
|
||||||
} else {
|
|
||||||
NSSet<NSString *> *existingNames = [NSSet setWithArray:existingEntries ?: @[]];
|
|
||||||
destinationName = preferredDestinationName;
|
|
||||||
NSUInteger suffix = 2;
|
|
||||||
while ([existingNames containsObject:destinationName]) {
|
|
||||||
destinationName = [NSString stringWithFormat:@"%@-%lu", preferredDestinationName, (unsigned long)suffix];
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSString *destinationPath = [collectionPath stringByAppendingPathComponent:destinationName];
|
|
||||||
const int renameStatus = functions.AFCRenamePath(
|
|
||||||
afcConnection,
|
|
||||||
stagedItemPath.fileSystemRepresentation,
|
|
||||||
destinationPath.fileSystemRepresentation
|
|
||||||
);
|
|
||||||
if (renameStatus != 0) {
|
|
||||||
if (error != NULL) {
|
|
||||||
*error = WMMMakeError(
|
|
||||||
renameStatus,
|
|
||||||
[NSString stringWithFormat:@"AFCRenamePath failed from %@ to %@ (%d).", stagedItemPath, destinationPath, renameStatus]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = NO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
WMMRemoveAFCTree(&functions, afcConnection, stagedItemPath);
|
|
||||||
destinationName = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
|
||||||
functions.AMDeviceRelease(device);
|
|
||||||
return destinationName;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,26 +18,68 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
await accessStatus(for: source).availability
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||||
return .unavailable
|
return SourceAccessStatus(
|
||||||
|
availability: .unavailable,
|
||||||
|
mode: .unknown,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "iphone.gen3",
|
||||||
|
statusText: "Device source unavailable",
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fallbackMode: SourceAccessMode = expectedDevice.connection == .usb ? .usbDevice : .networkDevice
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let devices = try await listConnectedDevices()
|
let devices = try await listConnectedDevices()
|
||||||
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
|
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
|
||||||
return .disconnected
|
return SourceAccessStatus(
|
||||||
|
availability: .disconnected,
|
||||||
|
mode: fallbackMode,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "iphone.gen3",
|
||||||
|
statusText: "Device disconnected",
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mode: SourceAccessMode = device.connection == .usb ? .usbDevice : .networkDevice
|
||||||
|
let availability: SourceAvailability
|
||||||
|
let statusText: String?
|
||||||
switch device.trustState {
|
switch device.trustState {
|
||||||
case .trusted:
|
case .trusted:
|
||||||
return .available
|
availability = .available
|
||||||
|
statusText = nil
|
||||||
case .locked, .untrusted:
|
case .locked, .untrusted:
|
||||||
return .limited
|
availability = .limited
|
||||||
|
statusText = "Unlock and trust the device"
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
return .disconnected
|
availability = .disconnected
|
||||||
|
statusText = "Device unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return SourceAccessStatus(
|
||||||
|
availability: availability,
|
||||||
|
mode: mode,
|
||||||
|
displayName: device.name,
|
||||||
|
iconSystemName: "iphone.gen3",
|
||||||
|
statusText: statusText,
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return .disconnected
|
return SourceAccessStatus(
|
||||||
|
availability: .disconnected,
|
||||||
|
mode: fallbackMode,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "iphone.gen3",
|
||||||
|
statusText: "Device status unavailable",
|
||||||
|
warningText: error.localizedDescription
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,31 +407,6 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
|
||||||
guard case .connectedDevice(_, let container) = source.origin else {
|
|
||||||
throw SourceAccessError.accessFailed(reason: "The selected source is not a connected device.")
|
|
||||||
}
|
|
||||||
let minecraftRoot = container.minecraftFolderRelativePath?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !minecraftRoot.isEmpty else {
|
|
||||||
throw SourceAccessError.accessFailed(reason: "The connected device is missing its Minecraft content path.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let destinationName = try await AppleMobileDeviceAccess.installDirectory(
|
|
||||||
deviceIdentifier: container.deviceUDID,
|
|
||||||
bundleIdentifier: container.appID,
|
|
||||||
minecraftRootRelativePath: minecraftRoot,
|
|
||||||
collectionFolderName: payload.contentType.collectionFolderName,
|
|
||||||
preferredDestinationName: payload.suggestedFolderName,
|
|
||||||
sourceDirectoryURL: payload.preparedDirectoryURL
|
|
||||||
)
|
|
||||||
return InstalledContentItem(
|
|
||||||
contentType: payload.contentType,
|
|
||||||
displayName: payload.displayName,
|
|
||||||
destinationName: destinationName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
guard source.origin.kind == .connectedDevice else {
|
guard source.origin.kind == .connectedDevice else {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -10,7 +10,10 @@ 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 discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error>
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||||
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
@ -18,6 +21,10 @@ protocol SourceAccessMethod: Sendable {
|
|||||||
mode: SourceDiscoveryMode,
|
mode: SourceDiscoveryMode,
|
||||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
nonisolated func scanEvents(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode
|
||||||
|
) -> AsyncThrowingStream<ProviderEvent, Error>
|
||||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
||||||
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
|
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
|
||||||
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
||||||
@ -25,7 +32,6 @@ protocol SourceAccessMethod: Sendable {
|
|||||||
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem]
|
||||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry]
|
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry]
|
||||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
||||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem
|
|
||||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +40,17 @@ extension SourceAccessMethod {
|
|||||||
String(reflecting: Self.self)
|
String(reflecting: Self.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
|
_ = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
SourceAccessDescriptor(
|
SourceAccessDescriptor(
|
||||||
accessorIdentifier: accessorIdentifier,
|
accessorIdentifier: accessorIdentifier,
|
||||||
@ -43,8 +60,13 @@ extension SourceAccessMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
_ = source
|
await accessStatus(for: source).availability
|
||||||
return .unknown
|
}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
|
var status = source.origin.defaultAccessStatus(displayName: source.displayName)
|
||||||
|
status.availability = .unknown
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
||||||
@ -61,6 +83,64 @@ extension SourceAccessMethod {
|
|||||||
_ = onDiscovered
|
_ = onDiscovered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func scanEvents(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode
|
||||||
|
) -> AsyncThrowingStream<ProviderEvent, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task.detached(priority: .userInitiated) {
|
||||||
|
let accessStatus = await accessStatus(for: source)
|
||||||
|
continuation.yield(.accessStatusChanged(accessStatus))
|
||||||
|
continuation.yield(
|
||||||
|
.stageUpdated(
|
||||||
|
WorkStage(
|
||||||
|
id: "discovery",
|
||||||
|
title: "Discovering content",
|
||||||
|
detail: nil,
|
||||||
|
state: .running,
|
||||||
|
progress: .indeterminate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await discoverItems(for: source, mode: mode) { item in
|
||||||
|
continuation.yield(.discovered(item))
|
||||||
|
}
|
||||||
|
continuation.yield(
|
||||||
|
.stageUpdated(
|
||||||
|
WorkStage(
|
||||||
|
id: "discovery",
|
||||||
|
title: "Discovering content",
|
||||||
|
detail: nil,
|
||||||
|
state: .succeeded,
|
||||||
|
progress: .indeterminate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.yield(
|
||||||
|
.stageUpdated(
|
||||||
|
WorkStage(
|
||||||
|
id: "discovery",
|
||||||
|
title: "Discovering content",
|
||||||
|
detail: error.localizedDescription,
|
||||||
|
state: .failed,
|
||||||
|
progress: .indeterminate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
_ = source
|
_ = source
|
||||||
return item
|
return item
|
||||||
@ -109,12 +189,6 @@ extension SourceAccessMethod {
|
|||||||
return item.folderURL
|
return item.folderURL
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
|
||||||
_ = payload
|
|
||||||
_ = source
|
|
||||||
throw SourceAccessError.accessFailed(reason: "This source does not support installing Minecraft content.")
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
_ = source
|
_ = source
|
||||||
}
|
}
|
||||||
@ -130,9 +204,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
|
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||||
|
javaLocalFolderAccess: SourceAccessMethod = JavaLocalFolderSourceAccess(),
|
||||||
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
||||||
) {
|
) {
|
||||||
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess])
|
self.init(accessMethods: [localFolderAccess, javaLocalFolderAccess, connectedDeviceAccess])
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(accessMethods: [any SourceAccessMethod]) {
|
nonisolated init(accessMethods: [any SourceAccessMethod]) {
|
||||||
@ -159,6 +234,64 @@ 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 discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let accessMethods = Array(accessMethodsByIdentifier.values)
|
||||||
|
let task = Task.detached(priority: .userInitiated) {
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for accessMethod in accessMethods {
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
for try await event in accessMethod.discoverSourceCandidates() {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continuation.yield(
|
||||||
|
.warning(
|
||||||
|
ProviderWarning(
|
||||||
|
id: "\(accessMethod.accessorIdentifier)-candidate-discovery-failed",
|
||||||
|
message: "Source discovery failed",
|
||||||
|
detail: error.localizedDescription
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func discoverItems(
|
nonisolated func discoverItems(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
mode: SourceDiscoveryMode,
|
mode: SourceDiscoveryMode,
|
||||||
@ -171,6 +304,13 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func scanEvents(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode
|
||||||
|
) -> AsyncThrowingStream<ProviderEvent, Error> {
|
||||||
|
accessMethod(for: source).scanEvents(for: source, mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
accessMethod(for: source).accessDescriptor(for: source)
|
accessMethod(for: source).accessDescriptor(for: source)
|
||||||
}
|
}
|
||||||
@ -179,6 +319,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
return await accessMethod(for: source).availability(for: source)
|
return await accessMethod(for: source).availability(for: source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
|
return await accessMethod(for: source).accessStatus(for: source)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
||||||
return await accessMethod(for: source).capabilities(for: source)
|
return await accessMethod(for: source).capabilities(for: source)
|
||||||
}
|
}
|
||||||
@ -211,10 +355,6 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
|
||||||
return try await accessMethod(for: source).install(payload, in: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,17 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct LocalFolderSourceAccess: SourceAccessMethod {
|
typealias LocalFolderSourceAccess = BedrockLocalFolderSourceAccess
|
||||||
|
|
||||||
|
struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
|
||||||
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
|
||||||
|
|
||||||
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(
|
||||||
@ -18,9 +24,15 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
await accessStatus(for: source).availability
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
let candidateURL: URL
|
let candidateURL: URL
|
||||||
|
let mode: SourceAccessMode
|
||||||
if case .localFolder(let bookmarkData) = source.origin,
|
if case .localFolder(let bookmarkData) = source.origin,
|
||||||
let bookmarkData {
|
let bookmarkData {
|
||||||
|
mode = .securityScopedLocalFolder
|
||||||
var isStale = false
|
var isStale = false
|
||||||
if let resolvedURL = try? URL(
|
if let resolvedURL = try? URL(
|
||||||
resolvingBookmarkData: bookmarkData,
|
resolvingBookmarkData: bookmarkData,
|
||||||
@ -33,10 +45,19 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
candidateURL = source.folderURL
|
candidateURL = source.folderURL
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
mode = .localFileSystem
|
||||||
candidateURL = source.folderURL
|
candidateURL = source.folderURL
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
|
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
|
||||||
|
return SourceAccessStatus(
|
||||||
|
availability: availability,
|
||||||
|
mode: mode,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "folder",
|
||||||
|
statusText: availability == .available ? nil : "Folder unavailable",
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
||||||
@ -133,97 +154,6 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
return item.folderURL
|
return item.folderURL
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func install(_ payload: InstallationPayload, in source: MinecraftSource) async throws -> InstalledContentItem {
|
|
||||||
let sourceRootURL = try resolvedSourceRootURL(for: source)
|
|
||||||
let accessedSecurityScope = sourceRootURL.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if accessedSecurityScope {
|
|
||||||
sourceRootURL.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let contentRootURL = try resolvedContentRootURL(for: source, sourceRootURL: sourceRootURL)
|
|
||||||
let collectionURL = contentRootURL.appendingPathComponent(
|
|
||||||
payload.contentType.collectionFolderName,
|
|
||||||
isDirectory: true
|
|
||||||
)
|
|
||||||
let stagingRootURL = contentRootURL
|
|
||||||
.appendingPathComponent(".world-manager-staging", isDirectory: true)
|
|
||||||
let stagedItemURL = stagingRootURL
|
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
|
|
||||||
try fileManager.createDirectory(at: stagingRootURL, withIntermediateDirectories: true)
|
|
||||||
do {
|
|
||||||
try fileManager.copyItem(at: payload.preparedDirectoryURL, to: stagedItemURL)
|
|
||||||
try fileManager.createDirectory(at: collectionURL, withIntermediateDirectories: true)
|
|
||||||
let destinationURL = uniqueDestinationURL(
|
|
||||||
in: collectionURL,
|
|
||||||
preferredName: payload.suggestedFolderName,
|
|
||||||
fileManager: fileManager
|
|
||||||
)
|
|
||||||
try fileManager.moveItem(at: stagedItemURL, to: destinationURL)
|
|
||||||
return InstalledContentItem(
|
|
||||||
contentType: payload.contentType,
|
|
||||||
displayName: payload.displayName,
|
|
||||||
destinationName: destinationURL.lastPathComponent
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
try? fileManager.removeItem(at: stagedItemURL)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private func resolvedSourceRootURL(for source: MinecraftSource) throws -> URL {
|
|
||||||
guard case .localFolder(let bookmarkData) = source.origin, let bookmarkData else {
|
|
||||||
return source.folderURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var isStale = false
|
|
||||||
return try URL(
|
|
||||||
resolvingBookmarkData: bookmarkData,
|
|
||||||
options: [.withSecurityScope],
|
|
||||||
relativeTo: nil,
|
|
||||||
bookmarkDataIsStale: &isStale
|
|
||||||
).standardizedFileURL
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private func resolvedContentRootURL(
|
|
||||||
for source: MinecraftSource,
|
|
||||||
sourceRootURL: URL
|
|
||||||
) throws -> URL {
|
|
||||||
let observedRoots = Set(source.rawItems.map {
|
|
||||||
$0.collectionRootURL.deletingLastPathComponent().standardizedFileURL
|
|
||||||
})
|
|
||||||
if let minimumDepth = observedRoots.map(\.pathComponents.count).min() {
|
|
||||||
let shallowestRoots = observedRoots.filter { $0.pathComponents.count == minimumDepth }
|
|
||||||
if shallowestRoots.count == 1, let observedRoot = shallowestRoots.first {
|
|
||||||
return observedRoot
|
|
||||||
}
|
|
||||||
if shallowestRoots.count > 1 {
|
|
||||||
throw SourceAccessError.accessFailed(
|
|
||||||
reason: "This source contains more than one Minecraft content root. Choose the specific com.mojang folder before importing."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sourceRootURL
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private func uniqueDestinationURL(
|
|
||||||
in collectionURL: URL,
|
|
||||||
preferredName: String,
|
|
||||||
fileManager: FileManager
|
|
||||||
) -> URL {
|
|
||||||
var candidateURL = collectionURL.appendingPathComponent(preferredName, isDirectory: true)
|
|
||||||
var suffix = 2
|
|
||||||
while fileManager.fileExists(atPath: candidateURL.path) {
|
|
||||||
candidateURL = collectionURL.appendingPathComponent("\(preferredName)-\(suffix)", isDirectory: true)
|
|
||||||
suffix += 1
|
|
||||||
}
|
|
||||||
return candidateURL
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private func discoverItemsByReconcilingCache(
|
nonisolated private func discoverItemsByReconcilingCache(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
snapshot: SourceSnapshot,
|
snapshot: SourceSnapshot,
|
||||||
@ -282,3 +212,185 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
return components.first.map(String.init)
|
return components.first.map(String.init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
||||||
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder"
|
||||||
|
private let candidateDiscoveryRoots: [URL]?
|
||||||
|
|
||||||
|
nonisolated init(candidateDiscoveryRoots: [URL]? = nil) {
|
||||||
|
self.candidateDiscoveryRoots = candidateDiscoveryRoots
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
|
JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverSourceCandidates() -> AsyncThrowingStream<SourceCandidateEvent, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let roots = candidateDiscoveryRoots
|
||||||
|
let providerID = accessorIdentifier
|
||||||
|
let task = Task.detached(priority: .utility) {
|
||||||
|
continuation.yield(
|
||||||
|
.stageUpdated(
|
||||||
|
WorkStage(
|
||||||
|
id: "\(providerID)-candidate-discovery",
|
||||||
|
title: "Finding Java sources",
|
||||||
|
detail: nil,
|
||||||
|
state: .running,
|
||||||
|
progress: .indeterminate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let candidates = JavaContentScanner.discoverSourceCandidates(
|
||||||
|
providerID: providerID,
|
||||||
|
searchRoots: roots
|
||||||
|
)
|
||||||
|
for candidate in candidates {
|
||||||
|
continuation.yield(.candidate(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(
|
||||||
|
.stageUpdated(
|
||||||
|
WorkStage(
|
||||||
|
id: "\(providerID)-candidate-discovery",
|
||||||
|
title: "Finding Java sources",
|
||||||
|
detail: candidates.isEmpty ? "No Java sources found." : "Found \(candidates.count) Java sources.",
|
||||||
|
state: .succeeded,
|
||||||
|
progress: .indeterminate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
_ = source
|
||||||
|
return SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: accessorIdentifier,
|
||||||
|
kind: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
|
let candidateURL: URL
|
||||||
|
let mode: SourceAccessMode
|
||||||
|
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(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [.withSecurityScope],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
) {
|
||||||
|
candidateURL = resolvedURL.standardizedFileURL
|
||||||
|
} else {
|
||||||
|
candidateURL = source.folderURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mode = .localFileSystem
|
||||||
|
candidateURL = source.folderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let availability: SourceAvailability = FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
|
||||||
|
return SourceAccessStatus(
|
||||||
|
availability: availability,
|
||||||
|
mode: mode,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "folder",
|
||||||
|
statusText: availability == .available ? nil : "Folder unavailable",
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
||||||
|
_ = source
|
||||||
|
return .localFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws {
|
||||||
|
_ = mode
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedURL: URL
|
||||||
|
if let bookmarkData {
|
||||||
|
var isStale = false
|
||||||
|
guard let bookmarkURL = try? URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [.withSecurityScope],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
) else {
|
||||||
|
throw SourceAccessError.accessFailed(
|
||||||
|
reason: "The saved folder bookmark could not be resolved."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedURL = bookmarkURL.standardizedFileURL
|
||||||
|
} else {
|
||||||
|
resolvedURL = source.folderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessedSecurityScope {
|
||||||
|
resolvedURL.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try JavaContentScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return await JavaContentScanner.enrich(item: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return JavaContentScanner.loadSize(for: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
_ = source
|
||||||
|
return item.folderURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,133 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectedDeviceDetailView: View {
|
||||||
|
let entry: ConnectedDeviceSidebarEntry
|
||||||
|
let addAction: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(entry.device.name)
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
|
||||||
|
Text("Available connected device")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let addAction {
|
||||||
|
Button("Add Source") {
|
||||||
|
addAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSection(title: "Overview", rows: overviewRows)
|
||||||
|
sourceSection(title: "Minecraft Access", rows: minecraftRows)
|
||||||
|
sourceSection(title: "Technical Details", rows: technicalRows)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 760, alignment: .leading)
|
||||||
|
.padding(28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var overviewRows: [(String, String)] {
|
||||||
|
var rows: [(String, String)] = [
|
||||||
|
("Connection", connectionLabel),
|
||||||
|
("Trust State", trustStateLabel),
|
||||||
|
("Availability", entry.hasMinecraftContainer ? "Ready to add" : "Not ready")
|
||||||
|
]
|
||||||
|
|
||||||
|
if let productType = entry.device.productType, !productType.isEmpty {
|
||||||
|
rows.append(("Product Type", productType))
|
||||||
|
}
|
||||||
|
if let osVersion = entry.device.osVersion, !osVersion.isEmpty {
|
||||||
|
rows.append(("OS Version", osVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private var minecraftRows: [(String, String)] {
|
||||||
|
if let error = entry.discoveryErrorDescription, !error.isEmpty {
|
||||||
|
return [("Discovery Error", error)]
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let container = entry.minecraftContainer else {
|
||||||
|
return [("Minecraft Container", "Not found")]
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows: [(String, String)] = [
|
||||||
|
("Minecraft Container", container.appName),
|
||||||
|
("App ID", container.appID),
|
||||||
|
("Access Mode", container.accessMode.rawValue)
|
||||||
|
]
|
||||||
|
|
||||||
|
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
|
||||||
|
rows.append(("Minecraft Path", relativePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private var technicalRows: [(String, String)] {
|
||||||
|
[
|
||||||
|
("UDID", entry.device.udid),
|
||||||
|
("Device ID", entry.id)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionLabel: String {
|
||||||
|
switch entry.device.connection {
|
||||||
|
case .usb:
|
||||||
|
return "USB"
|
||||||
|
case .network:
|
||||||
|
return "Network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trustStateLabel: String {
|
||||||
|
switch entry.device.trustState {
|
||||||
|
case .trusted:
|
||||||
|
return "Trusted"
|
||||||
|
case .locked:
|
||||||
|
return "Locked"
|
||||||
|
case .untrusted:
|
||||||
|
return "Untrusted"
|
||||||
|
case .unavailable:
|
||||||
|
return "Unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.appSectionTitleStyle(.section)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(rows, id: \.0) { title, value in
|
||||||
|
detailRow(title: title, value: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appDetailSectionCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(title)
|
||||||
|
.appTextStyle(.fieldLabel)
|
||||||
|
.frame(width: 150, alignment: .leading)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,8 @@ import SwiftUI
|
|||||||
struct ItemDetailColumnView: View {
|
struct ItemDetailColumnView: View {
|
||||||
let item: MinecraftContentItem?
|
let item: MinecraftContentItem?
|
||||||
let source: MinecraftSource?
|
let source: MinecraftSource?
|
||||||
let installationState: SourceInstallationState?
|
let sourceCandidate: SourceCandidate?
|
||||||
|
let connectedDevice: ConnectedDeviceSidebarEntry?
|
||||||
let showsSourceDetails: Bool
|
let showsSourceDetails: Bool
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
@ -23,12 +24,13 @@ struct ItemDetailColumnView: View {
|
|||||||
let exportAction: () -> Void
|
let exportAction: () -> Void
|
||||||
let revealAction: () -> Void
|
let revealAction: () -> Void
|
||||||
let shareAction: (NSView?) -> Void
|
let shareAction: (NSView?) -> Void
|
||||||
let importAction: (MinecraftSource) -> Void
|
let addCandidateSourceAction: (SourceCandidate) -> Void
|
||||||
|
let revealCandidateAction: (SourceCandidate) -> Void
|
||||||
|
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if isEmpty {
|
if let item {
|
||||||
} else if let item {
|
|
||||||
ItemDetailView(
|
ItemDetailView(
|
||||||
item: item,
|
item: item,
|
||||||
source: source,
|
source: source,
|
||||||
@ -47,13 +49,25 @@ struct ItemDetailColumnView: View {
|
|||||||
shareAction: shareAction
|
shareAction: shareAction
|
||||||
)
|
)
|
||||||
} else if showsSourceDetails, let source {
|
} else if showsSourceDetails, let source {
|
||||||
SourceDetailView(
|
SourceDetailView(source: source)
|
||||||
source: source,
|
} else if let sourceCandidate {
|
||||||
installationState: installationState,
|
SourceCandidateDetailView(
|
||||||
importAction: {
|
candidate: sourceCandidate,
|
||||||
importAction(source)
|
addAction: {
|
||||||
|
addCandidateSourceAction(sourceCandidate)
|
||||||
|
},
|
||||||
|
revealAction: {
|
||||||
|
revealCandidateAction(sourceCandidate)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} else if let connectedDevice {
|
||||||
|
ConnectedDeviceDetailView(
|
||||||
|
entry: connectedDevice,
|
||||||
|
addAction: connectedDevice.hasMinecraftContainer ? {
|
||||||
|
addConnectedDeviceAction(connectedDevice)
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
} else if isEmpty {
|
||||||
} else {
|
} else {
|
||||||
Text("Select a world or pack to see details")
|
Text("Select a world or pack to see details")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@ -162,7 +162,7 @@ struct ItemDetailView: View {
|
|||||||
recordSection(title: "Technical Details") {
|
recordSection(title: "Technical Details") {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
detailRow(title: "Folder ID", value: item.folderID)
|
detailRow(title: "Folder ID", value: item.folderID)
|
||||||
detailRow(title: "Type", value: item.contentType.rawValue)
|
detailRow(title: "Type", value: item.platformType.displayName)
|
||||||
detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent)
|
detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent)
|
||||||
if let spawn = item.worldMetadata?.spawn {
|
if let spawn = item.worldMetadata?.spawn {
|
||||||
detailValueRow(title: "Spawn", value: spawn)
|
detailValueRow(title: "Spawn", value: spawn)
|
||||||
@ -249,13 +249,49 @@ struct ItemDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.contentType == .behaviorPack || item.contentType == .resourcePack {
|
if item.sourceEdition == .bedrock && (item.contentType == .behaviorPack || item.contentType == .resourcePack) {
|
||||||
detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable")
|
detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable")
|
||||||
detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable")
|
detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable")
|
||||||
if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion {
|
if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion {
|
||||||
detailValueRow(title: "Minimum Engine", value: minimumEngineVersion)
|
detailValueRow(title: "Minimum Engine", value: minimumEngineVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let javaPackMetadata {
|
||||||
|
if let description = javaPackMetadata.description {
|
||||||
|
detailRow(title: javaModMetadata == nil ? "Description" : "Pack Description", value: description)
|
||||||
|
}
|
||||||
|
if let packFormat = javaPackMetadata.packFormat {
|
||||||
|
detailValueRow(title: "Pack Format", value: String(packFormat))
|
||||||
|
}
|
||||||
|
if let supportedFormats = javaPackMetadata.supportedFormats {
|
||||||
|
detailValueRow(title: "Supported Formats", value: supportedFormats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let javaModMetadata {
|
||||||
|
if let modID = javaModMetadata.modID {
|
||||||
|
detailValueRow(title: "Mod ID", value: modID)
|
||||||
|
}
|
||||||
|
if let version = javaModMetadata.version {
|
||||||
|
detailValueRow(title: "Mod Version", value: version)
|
||||||
|
}
|
||||||
|
if let description = javaModMetadata.description {
|
||||||
|
detailRow(title: "Mod Description", value: description)
|
||||||
|
}
|
||||||
|
if !javaModMetadata.authors.isEmpty {
|
||||||
|
detailValueRow(title: "Authors", value: javaModMetadata.authors.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
if let license = javaModMetadata.license {
|
||||||
|
detailValueRow(title: "License", value: license)
|
||||||
|
}
|
||||||
|
if let environment = javaModMetadata.environment {
|
||||||
|
detailValueRow(title: "Environment", value: environment)
|
||||||
|
}
|
||||||
|
if let minecraftRequirement = javaModMetadata.minecraftVersionRequirement {
|
||||||
|
detailValueRow(title: "Minecraft", value: minecraftRequirement)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,7 +385,13 @@ struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var heroMetadata: [String] {
|
private var heroMetadata: [String] {
|
||||||
var chips = [item.contentType.rawValue, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
|
var chips = [item.platformType.displayName, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
|
||||||
|
|
||||||
|
if let modID = javaModMetadata?.modID {
|
||||||
|
chips.append(modID)
|
||||||
|
} else if let packFormat = javaPackMetadata?.packFormat {
|
||||||
|
chips.append("Format \(packFormat)")
|
||||||
|
}
|
||||||
|
|
||||||
if item.contentType == .world {
|
if item.contentType == .world {
|
||||||
let packCount = behaviorPacks.count + resourcePacks.count
|
let packCount = behaviorPacks.count + resourcePacks.count
|
||||||
@ -388,6 +430,22 @@ struct ItemDetailView: View {
|
|||||||
return max(0, relatedWorldIDs.subtracting([item.id]).count)
|
return max(0, relatedWorldIDs.subtracting([item.id]).count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var javaPackMetadata: JavaPackMetadata? {
|
||||||
|
if case .java(let metadata) = item.platformMetadata {
|
||||||
|
return metadata.pack
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var javaModMetadata: JavaModMetadata? {
|
||||||
|
if case .java(let metadata) = item.platformMetadata {
|
||||||
|
return metadata.mod
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRowExportTitle: String {
|
private var actionRowExportTitle: String {
|
||||||
if exportTitle != nil {
|
if exportTitle != nil {
|
||||||
switch item.contentType {
|
switch item.contentType {
|
||||||
|
|||||||
@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourceCandidateDetailView: View {
|
||||||
|
let candidate: SourceCandidate
|
||||||
|
let addAction: () -> Void
|
||||||
|
let revealAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(candidate.displayName)
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
|
||||||
|
Text("Found source candidate")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button("Add Source") {
|
||||||
|
addAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Reveal in Finder") {
|
||||||
|
revealAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSection(title: "Overview", rows: overviewRows)
|
||||||
|
sourceSection(title: "Detected Content", rows: contentRows)
|
||||||
|
sourceSection(title: "Location", rows: locationRows)
|
||||||
|
sourceSection(title: "Technical Details", rows: technicalRows)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 760, alignment: .leading)
|
||||||
|
.padding(28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var overviewRows: [(String, String)] {
|
||||||
|
[
|
||||||
|
("Edition", editionLabel),
|
||||||
|
("Provider", providerLabel),
|
||||||
|
("Confidence", confidenceLabel),
|
||||||
|
("Reason", candidate.reason)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentRows: [(String, String)] {
|
||||||
|
guard !candidate.detectedKinds.isEmpty else {
|
||||||
|
return [("Detected Kinds", "None")]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [("Detected Kinds", orderedKindLabels.joined(separator: ", "))]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var locationRows: [(String, String)] {
|
||||||
|
[
|
||||||
|
("Filesystem Path", candidate.sourceRootURL.path)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var technicalRows: [(String, String)] {
|
||||||
|
[
|
||||||
|
("Provider ID", candidate.providerID),
|
||||||
|
("Candidate ID", candidate.id)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editionLabel: String {
|
||||||
|
switch candidate.edition {
|
||||||
|
case .bedrock:
|
||||||
|
return "Bedrock"
|
||||||
|
case .java:
|
||||||
|
return "Java"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerLabel: String {
|
||||||
|
switch candidate.providerID {
|
||||||
|
case JavaLocalFolderSourceAccess().accessorIdentifier:
|
||||||
|
return "Java Local Folder"
|
||||||
|
case LocalFolderSourceAccess().accessorIdentifier:
|
||||||
|
return "Bedrock Local Folder"
|
||||||
|
case AppleMobileDeviceSourceAccess().accessorIdentifier:
|
||||||
|
return "Bedrock iOS Device"
|
||||||
|
default:
|
||||||
|
return candidate.providerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var confidenceLabel: String {
|
||||||
|
switch candidate.confidence {
|
||||||
|
case .none:
|
||||||
|
return "None"
|
||||||
|
case .weak:
|
||||||
|
return "Weak"
|
||||||
|
case .medium:
|
||||||
|
return "Medium"
|
||||||
|
case .strong:
|
||||||
|
return "Strong"
|
||||||
|
case .exact:
|
||||||
|
return "Exact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var orderedKindLabels: [String] {
|
||||||
|
let orderedKinds: [(MinecraftContentKind, String)] = [
|
||||||
|
(.world, "Worlds"),
|
||||||
|
(.behaviorPack, "Behavior Packs"),
|
||||||
|
(.resourcePack, "Resource Packs"),
|
||||||
|
(.dataPack, "Data Packs"),
|
||||||
|
(.skinPack, "Skin Packs"),
|
||||||
|
(.worldTemplate, "World Templates"),
|
||||||
|
(.shaderPack, "Shader Packs"),
|
||||||
|
(.mod, "Mods")
|
||||||
|
]
|
||||||
|
|
||||||
|
return orderedKinds.compactMap { kind, label in
|
||||||
|
candidate.detectedKinds.contains(kind) ? label : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.appSectionTitleStyle(.section)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(rows, id: \.0) { title, value in
|
||||||
|
detailRow(title: title, value: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.appDetailSectionCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(title)
|
||||||
|
.appTextStyle(.fieldLabel)
|
||||||
|
.frame(width: 150, alignment: .leading)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,37 +20,12 @@ struct SourceDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
let installationState: SourceInstallationState?
|
|
||||||
let importAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
Text(source.displayName)
|
||||||
Text(source.displayName)
|
.font(.largeTitle.weight(.semibold))
|
||||||
.font(.largeTitle.weight(.semibold))
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Import...", action: importAction)
|
|
||||||
.disabled(
|
|
||||||
source.availability != .available ||
|
|
||||||
!source.capabilities.canInstallItems ||
|
|
||||||
installationState != nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let installationState {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
ProgressView(
|
|
||||||
value: Double(installationState.completedCount),
|
|
||||||
total: Double(max(installationState.totalCount, 1))
|
|
||||||
)
|
|
||||||
Text(installationState.status)
|
|
||||||
.appTextStyle(.supporting)
|
|
||||||
}
|
|
||||||
.appDetailSectionCard()
|
|
||||||
}
|
|
||||||
|
|
||||||
if showsStatusSection {
|
if showsStatusSection {
|
||||||
sourceStatusSection
|
sourceStatusSection
|
||||||
@ -208,7 +183,7 @@ struct SourceDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
break
|
break
|
||||||
case .connectedDevice(let device, let container):
|
case .connectedDevice(let device, let container):
|
||||||
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
|
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
|
||||||
@ -222,19 +197,31 @@ 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)] {
|
||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return [("Filesystem Path", source.folderURL.path)]
|
return [("Filesystem Path", source.folderURL.path)]
|
||||||
case .connectedDevice(_, let container):
|
case .connectedDevice(_, let container):
|
||||||
var rows: [(String, String)] = [
|
var rows: [(String, String)] = [
|
||||||
@ -249,7 +236,7 @@ struct SourceDetailView: View {
|
|||||||
|
|
||||||
private var technicalRows: [(String, String)] {
|
private var technicalRows: [(String, String)] {
|
||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return []
|
return []
|
||||||
case .connectedDevice(let device, let container):
|
case .connectedDevice(let device, let container):
|
||||||
var rows: [(String, String)] = [
|
var rows: [(String, String)] = [
|
||||||
@ -269,6 +256,8 @@ struct SourceDetailView: View {
|
|||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder:
|
||||||
return "Local Folder"
|
return "Local Folder"
|
||||||
|
case .javaLocalFolder:
|
||||||
|
return "Java Local Folder"
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return "Connected Device"
|
return "Connected Device"
|
||||||
}
|
}
|
||||||
@ -508,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) {
|
||||||
|
|||||||
@ -41,7 +41,6 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
let searchPrompt: String
|
let searchPrompt: String
|
||||||
let chooseFolderAction: () -> Void
|
let chooseFolderAction: () -> Void
|
||||||
let dropAction: ([NSItemProvider]) -> Bool
|
let dropAction: ([NSItemProvider]) -> Bool
|
||||||
let dragProvider: (MinecraftContentItem) -> NSItemProvider
|
|
||||||
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -56,14 +55,6 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
List(items, selection: $selectedItemID) { item in
|
List(items, selection: $selectedItemID) { item in
|
||||||
ContentRowView(item: item)
|
ContentRowView(item: item)
|
||||||
.tag(item.id)
|
.tag(item.id)
|
||||||
.onDrag {
|
|
||||||
dragProvider(item)
|
|
||||||
}
|
|
||||||
.simultaneousGesture(
|
|
||||||
TapGesture().onEnded {
|
|
||||||
selectedItemID = item.id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
itemContextMenu(item)
|
itemContextMenu(item)
|
||||||
}
|
}
|
||||||
@ -82,8 +73,8 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
showsSourceName: showsSourceName,
|
showsSourceName: showsSourceName,
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: subtitle,
|
subtitle: navigationSubtitleText,
|
||||||
showsSubtitle: showsSubtitle,
|
showsSubtitle: showsSubtitle || showsProjectionLoadingState,
|
||||||
isRefreshing: isRefreshing,
|
isRefreshing: isRefreshing,
|
||||||
showsProjectionLoadingState: showsProjectionLoadingState
|
showsProjectionLoadingState: showsProjectionLoadingState
|
||||||
)
|
)
|
||||||
@ -91,7 +82,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
}
|
}
|
||||||
.searchable(text: $searchText, prompt: searchPrompt)
|
.searchable(text: $searchText, prompt: searchPrompt)
|
||||||
.navigationTitle(isEmpty ? "Library" : title)
|
.navigationTitle(isEmpty ? "Library" : title)
|
||||||
.navigationSubtitle(isEmpty ? "" : subtitle)
|
.navigationSubtitle(isEmpty ? "" : navigationSubtitleText)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !isEmpty {
|
if !isEmpty {
|
||||||
ToolbarItemGroup {
|
ToolbarItemGroup {
|
||||||
@ -109,6 +100,13 @@ struct ItemListColumnView<MenuContent: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var navigationSubtitleText: String {
|
||||||
|
if showsProjectionLoadingState {
|
||||||
|
return "Loading items..."
|
||||||
|
}
|
||||||
|
return subtitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ItemListHeaderView: View {
|
private struct ItemListHeaderView: View {
|
||||||
@ -139,8 +137,8 @@ private struct ItemListHeaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showsSubtitle || showsProjectionLoadingState {
|
if showsSubtitle {
|
||||||
Text(displaySubtitle)
|
Text(subtitle)
|
||||||
.appTextStyle(.supporting)
|
.appTextStyle(.supporting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -150,14 +148,6 @@ private struct ItemListHeaderView: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.appListHeaderSurface()
|
.appListHeaderSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var displaySubtitle: String {
|
|
||||||
if showsProjectionLoadingState {
|
|
||||||
return "Loading items..."
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ItemListLoadingOverlay: View {
|
private struct ItemListLoadingOverlay: View {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import SwiftUI
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
||||||
enum PreviewFixtures {
|
nonisolated enum PreviewFixtures {
|
||||||
static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000)
|
static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000)
|
||||||
|
|
||||||
static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1")
|
static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1")
|
||||||
@ -132,7 +132,7 @@ enum PreviewFixtures {
|
|||||||
)
|
)
|
||||||
|
|
||||||
static let primarySource: MinecraftSource = {
|
static let primarySource: MinecraftSource = {
|
||||||
var source = MinecraftSource(folderURL: sourceOneURL)
|
var source = MinecraftSource(folderURL: sourceOneURL, availability: .available)
|
||||||
source.displayName = "Kid iPad Imports"
|
source.displayName = "Kid iPad Imports"
|
||||||
source.displayItems = [
|
source.displayItems = [
|
||||||
featuredWorld,
|
featuredWorld,
|
||||||
@ -144,6 +144,9 @@ enum PreviewFixtures {
|
|||||||
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||||
counts[item.contentType, default: 0] += 1
|
counts[item.contentType, default: 0] += 1
|
||||||
}
|
}
|
||||||
|
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
||||||
|
counts[item.contentKind, default: 0] += 1
|
||||||
|
}
|
||||||
source.rawItems = source.displayItems
|
source.rawItems = source.displayItems
|
||||||
source.logicalPacks = [
|
source.logicalPacks = [
|
||||||
LogicalPack(
|
LogicalPack(
|
||||||
@ -223,12 +226,15 @@ enum PreviewFixtures {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
static let secondarySource: MinecraftSource = {
|
static let secondarySource: MinecraftSource = {
|
||||||
var source = MinecraftSource(folderURL: sourceTwoURL)
|
var source = MinecraftSource(folderURL: sourceTwoURL, availability: .available)
|
||||||
source.displayName = "Downloads"
|
source.displayName = "Downloads"
|
||||||
source.displayItems = [secondLibraryPack]
|
source.displayItems = [secondLibraryPack]
|
||||||
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||||
counts[item.contentType, default: 0] += 1
|
counts[item.contentType, default: 0] += 1
|
||||||
}
|
}
|
||||||
|
source.displayItemCountsByKind = source.displayItems.reduce(into: [MinecraftContentKind: Int]()) { counts, item in
|
||||||
|
counts[item.contentKind, default: 0] += 1
|
||||||
|
}
|
||||||
source.rawItems = source.displayItems
|
source.rawItems = source.displayItems
|
||||||
source.indexedItemCount = source.displayItems.count
|
source.indexedItemCount = source.displayItems.count
|
||||||
source.indexedDetailCount = source.displayItems.count
|
source.indexedDetailCount = source.displayItems.count
|
||||||
@ -247,6 +253,87 @@ enum PreviewFixtures {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PreviewSourceAccess: SourceAccessMethod {
|
||||||
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "preview-source"
|
||||||
|
|
||||||
|
nonisolated init() {}
|
||||||
|
|
||||||
|
nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus {
|
||||||
|
SourceAccessStatus(
|
||||||
|
availability: .available,
|
||||||
|
mode: .localFileSystem,
|
||||||
|
displayName: source.displayName,
|
||||||
|
iconSystemName: "folder",
|
||||||
|
statusText: nil,
|
||||||
|
warningText: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities {
|
||||||
|
_ = source
|
||||||
|
return .localFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
mode: SourceDiscoveryMode,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws {
|
||||||
|
_ = mode
|
||||||
|
for item in source.displayItems {
|
||||||
|
onDiscovered(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] {
|
||||||
|
_ = item
|
||||||
|
_ = source
|
||||||
|
return PreviewFixtures.directoryEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
_ = source
|
||||||
|
return item.folderURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension SourceLibrary {
|
||||||
|
static func makePreview() -> SourceLibrary {
|
||||||
|
let library = SourceLibrary(
|
||||||
|
sourceAccessMethod: PreviewSourceAccess(),
|
||||||
|
restoresPersistedSources: false,
|
||||||
|
startsBackgroundRefresh: false
|
||||||
|
)
|
||||||
|
library.sources = PreviewFixtures.allSources
|
||||||
|
library.sourceCandidates = [
|
||||||
|
SourceCandidate(
|
||||||
|
providerID: LocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
edition: .bedrock,
|
||||||
|
sourceRootURL: URL(fileURLWithPath: "/tmp/preview-candidate"),
|
||||||
|
displayName: "Found Minecraft Folder",
|
||||||
|
confidence: .strong,
|
||||||
|
reason: "Contains Minecraft content folders",
|
||||||
|
detectedKinds: [.world, .resourcePack]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ContentViewDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func makePreview() -> ContentViewDependencies {
|
||||||
|
let connectedDeviceAccess = AppleMobileDeviceSourceAccess()
|
||||||
|
return ContentViewDependencies(
|
||||||
|
library: .makePreview(),
|
||||||
|
connectedDeviceAccess: connectedDeviceAccess,
|
||||||
|
deviceSourceFactory: ConnectedDeviceSourceFactory(),
|
||||||
|
itemActionService: ContentItemActionService()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct SidebarColumnPreviewContainer: View {
|
struct SidebarColumnPreviewContainer: View {
|
||||||
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
|
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
|
||||||
@ -256,15 +343,16 @@ struct SidebarColumnPreviewContainer: View {
|
|||||||
SourcesSidebarView(
|
SourcesSidebarView(
|
||||||
sources: PreviewFixtures.allSources,
|
sources: PreviewFixtures.allSources,
|
||||||
connectedDevices: [],
|
connectedDevices: [],
|
||||||
|
sourceCandidates: [],
|
||||||
|
isDiscoveringSourceCandidates: false,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
addSourceAction: {},
|
addSourceAction: {},
|
||||||
|
discoverSourcesAction: {},
|
||||||
|
addCandidateSourceAction: { _ in },
|
||||||
addDeviceSourceAction: {},
|
addDeviceSourceAction: {},
|
||||||
addConnectedDeviceAction: { _ in },
|
addConnectedDeviceAction: { _ in },
|
||||||
rescanSourceAction: { _ in },
|
rescanSourceAction: { _ in },
|
||||||
removeSourceAction: { _ in },
|
removeSourceAction: { _ in },
|
||||||
importSourceAction: { _ in },
|
|
||||||
importDropAction: { _, _ in false },
|
|
||||||
installationState: { _ in nil },
|
|
||||||
filters: { source in
|
filters: { source in
|
||||||
let allFilter = SidebarFilter(
|
let allFilter = SidebarFilter(
|
||||||
title: "All Content",
|
title: "All Content",
|
||||||
@ -334,7 +422,6 @@ struct ItemListColumnPreviewContainer: View {
|
|||||||
searchPrompt: "Search Worlds",
|
searchPrompt: "Search Worlds",
|
||||||
chooseFolderAction: {},
|
chooseFolderAction: {},
|
||||||
dropAction: { _ in false },
|
dropAction: { _ in false },
|
||||||
dragProvider: { _ in NSItemProvider() },
|
|
||||||
itemContextMenu: { item in
|
itemContextMenu: { item in
|
||||||
Button("Reveal \(item.displayName)") {}
|
Button("Reveal \(item.displayName)") {}
|
||||||
}
|
}
|
||||||
@ -349,7 +436,8 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: PreviewFixtures.featuredWorld,
|
item: PreviewFixtures.featuredWorld,
|
||||||
source: PreviewFixtures.primarySource,
|
source: PreviewFixtures.primarySource,
|
||||||
installationState: nil,
|
sourceCandidate: nil,
|
||||||
|
connectedDevice: nil,
|
||||||
showsSourceDetails: false,
|
showsSourceDetails: false,
|
||||||
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
||||||
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
||||||
@ -365,7 +453,9 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
exportAction: {},
|
exportAction: {},
|
||||||
revealAction: {},
|
revealAction: {},
|
||||||
shareAction: { _ in },
|
shareAction: { _ in },
|
||||||
importAction: { _ in }
|
addCandidateSourceAction: { _ in },
|
||||||
|
revealCandidateAction: { _ in },
|
||||||
|
addConnectedDeviceAction: { _ in }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ struct ContentView: View {
|
|||||||
@State private var isDropTargeted = false
|
@State private var isDropTargeted = false
|
||||||
@State private var isPerformingItemAction = false
|
@State private var isPerformingItemAction = false
|
||||||
@State private var isShowingDeviceSourceSheet = false
|
@State private var isShowingDeviceSourceSheet = false
|
||||||
@State private var importErrorMessage: String?
|
|
||||||
@State private var sortMode: ItemSortMode = .name
|
@State private var sortMode: ItemSortMode = .name
|
||||||
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
||||||
@State private var showsProjectionLoadingState = false
|
@State private var showsProjectionLoadingState = false
|
||||||
@ -33,19 +32,26 @@ struct ContentView: View {
|
|||||||
private let directoryPreviewLimit = 12
|
private let directoryPreviewLimit = 12
|
||||||
private let projectionLoadingDelay: Duration = .milliseconds(150)
|
private let projectionLoadingDelay: Duration = .milliseconds(150)
|
||||||
|
|
||||||
init() {
|
init(
|
||||||
let dependencies = ContentViewDependencies.makeDefault()
|
dependencies: ContentViewDependencies = ContentViewDependencies.makeDefault(),
|
||||||
|
initialSidebarSelection: SidebarSelection? = nil,
|
||||||
|
initialItemID: MinecraftContentItem.ID? = nil
|
||||||
|
) {
|
||||||
self.connectedDeviceAccess = dependencies.connectedDeviceAccess
|
self.connectedDeviceAccess = dependencies.connectedDeviceAccess
|
||||||
self.deviceSourceFactory = dependencies.deviceSourceFactory
|
self.deviceSourceFactory = dependencies.deviceSourceFactory
|
||||||
self.itemActionService = dependencies.itemActionService
|
self.itemActionService = dependencies.itemActionService
|
||||||
_library = StateObject(
|
_library = StateObject(
|
||||||
wrappedValue: dependencies.library
|
wrappedValue: dependencies.library
|
||||||
)
|
)
|
||||||
|
_selectedSidebarSelection = State(initialValue: initialSidebarSelection)
|
||||||
|
_selectedItemID = State(initialValue: initialItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
|
let isEmptyLibrary = library.visibleSources.isEmpty && library.sidebarConnectedDevices.isEmpty && library.sourceCandidates.isEmpty
|
||||||
let resolvedCurrentSource = currentSource
|
let resolvedCurrentSource = currentSource
|
||||||
|
let resolvedCurrentSourceCandidate = currentSourceCandidate
|
||||||
|
let resolvedCurrentConnectedDevice = currentConnectedDevice
|
||||||
let currentProjectionRequest = ItemCollectionProjectionRequest(
|
let currentProjectionRequest = ItemCollectionProjectionRequest(
|
||||||
selection: selectedSidebarSelection,
|
selection: selectedSidebarSelection,
|
||||||
searchText: searchText,
|
searchText: searchText,
|
||||||
@ -75,9 +81,15 @@ struct ContentView: View {
|
|||||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
SourcesSidebarView(
|
SourcesSidebarView(
|
||||||
sources: library.sidebarSources,
|
sources: library.sidebarSources,
|
||||||
connectedDevices: library.connectedDevices,
|
connectedDevices: library.sidebarConnectedDevices,
|
||||||
|
sourceCandidates: library.sourceCandidates,
|
||||||
|
isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates,
|
||||||
selection: sidebarSelectionBinding,
|
selection: sidebarSelectionBinding,
|
||||||
addSourceAction: pickFolder,
|
addSourceAction: pickFolder,
|
||||||
|
discoverSourcesAction: {
|
||||||
|
library.perform(.discoverSourceCandidates)
|
||||||
|
},
|
||||||
|
addCandidateSourceAction: addCandidateSource(_:),
|
||||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||||
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
||||||
rescanSourceAction: { source in
|
rescanSourceAction: { source in
|
||||||
@ -88,11 +100,6 @@ struct ContentView: View {
|
|||||||
removeSourceAction: { source in
|
removeSourceAction: { source in
|
||||||
removeSource(source.id)
|
removeSource(source.id)
|
||||||
},
|
},
|
||||||
importSourceAction: importIntoSource(_:),
|
|
||||||
importDropAction: handleImportDrop(on:providers:),
|
|
||||||
installationState: { source in
|
|
||||||
library.installationStateBySourceID[source.id]
|
|
||||||
},
|
|
||||||
filters: sidebarFilters(for:)
|
filters: sidebarFilters(for:)
|
||||||
)
|
)
|
||||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||||
@ -115,7 +122,6 @@ struct ContentView: View {
|
|||||||
searchPrompt: resolvedItemListProjection.searchPrompt,
|
searchPrompt: resolvedItemListProjection.searchPrompt,
|
||||||
chooseFolderAction: pickFolder,
|
chooseFolderAction: pickFolder,
|
||||||
dropAction: handleDroppedProviders(_:),
|
dropAction: handleDroppedProviders(_:),
|
||||||
dragProvider: dragProvider(for:),
|
|
||||||
itemContextMenu: itemContextMenu(for:)
|
itemContextMenu: itemContextMenu(for:)
|
||||||
)
|
)
|
||||||
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||||
@ -123,7 +129,8 @@ struct ContentView: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: resolvedCurrentSelectedItem,
|
item: resolvedCurrentSelectedItem,
|
||||||
source: resolvedCurrentSource,
|
source: resolvedCurrentSource,
|
||||||
installationState: resolvedCurrentSource.flatMap { library.installationStateBySourceID[$0.id] },
|
sourceCandidate: resolvedCurrentSourceCandidate,
|
||||||
|
connectedDevice: resolvedCurrentConnectedDevice,
|
||||||
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
||||||
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||||
@ -157,7 +164,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
shareItem(item, from: anchorView)
|
shareItem(item, from: anchorView)
|
||||||
},
|
},
|
||||||
importAction: importIntoSource(_:)
|
addCandidateSourceAction: addCandidateSource(_:),
|
||||||
|
revealCandidateAction: revealCandidateInFinder(_:),
|
||||||
|
addConnectedDeviceAction: addConnectedDeviceSource(from:)
|
||||||
)
|
)
|
||||||
.frame(minWidth: 450)
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
@ -178,23 +187,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.alert(
|
|
||||||
"Import Failed",
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { importErrorMessage != nil },
|
|
||||||
set: { isPresented in
|
|
||||||
if !isPresented {
|
|
||||||
importErrorMessage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Button("OK") {
|
|
||||||
importErrorMessage = nil
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text(importErrorMessage ?? "The Minecraft content could not be imported.")
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
AppTerminationCoordinator.shared.register(library: library)
|
AppTerminationCoordinator.shared.register(library: library)
|
||||||
}
|
}
|
||||||
@ -209,7 +201,7 @@ struct ContentView: View {
|
|||||||
.onChange(of: library.sources.map(\.id)) { _, _ in
|
.onChange(of: library.sources.map(\.id)) { _, _ in
|
||||||
syncSelection(with: library.visibleSources.map(\.id))
|
syncSelection(with: library.visibleSources.map(\.id))
|
||||||
}
|
}
|
||||||
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
|
.onChange(of: library.sidebarConnectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
|
||||||
syncSelection(with: library.visibleSources.map(\.id))
|
syncSelection(with: library.visibleSources.map(\.id))
|
||||||
}
|
}
|
||||||
.task(id: currentProjectionRequest) {
|
.task(id: currentProjectionRequest) {
|
||||||
@ -275,6 +267,22 @@ struct ContentView: View {
|
|||||||
return library.source(withID: sourceID)
|
return library.source(withID: sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentSourceCandidate: SourceCandidate? {
|
||||||
|
guard case .sourceCandidate(let candidateID) = selectedSidebarSelection else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return library.sourceCandidates.first { $0.id == candidateID }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentConnectedDevice: ConnectedDeviceSidebarEntry? {
|
||||||
|
guard case .connectedDevice(let deviceID) = selectedSidebarSelection else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return library.sidebarConnectedDevices.first { $0.id == deviceID }
|
||||||
|
}
|
||||||
|
|
||||||
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
|
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
|
||||||
guard let selectedItemID else {
|
guard let selectedItemID else {
|
||||||
return nil
|
return nil
|
||||||
@ -357,16 +365,27 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
return MinecraftContentType.allCases.compactMap { contentType in
|
let orderedKinds: [MinecraftContentKind] = [
|
||||||
guard let count = source.displayItemCountsByType[contentType], count > 0 else {
|
.world,
|
||||||
|
.behaviorPack,
|
||||||
|
.resourcePack,
|
||||||
|
.dataPack,
|
||||||
|
.skinPack,
|
||||||
|
.worldTemplate,
|
||||||
|
.shaderPack,
|
||||||
|
.mod
|
||||||
|
]
|
||||||
|
|
||||||
|
return orderedKinds.compactMap { contentKind in
|
||||||
|
guard let count = source.displayItemCountsByKind[contentKind], count > 0 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return SidebarFilter(
|
return SidebarFilter(
|
||||||
title: sidebarTitle(for: contentType),
|
title: sidebarTitle(for: contentKind),
|
||||||
iconName: sidebarIcon(for: contentType),
|
iconName: sidebarIcon(for: contentKind),
|
||||||
count: count,
|
count: count,
|
||||||
selection: .contentType(sourceID: source.id, contentType: contentType)
|
selection: .contentKind(sourceID: source.id, contentKind: contentKind)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -398,6 +417,27 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
|
||||||
|
switch contentKind {
|
||||||
|
case .world:
|
||||||
|
return "Worlds"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "Behavior Packs"
|
||||||
|
case .resourcePack:
|
||||||
|
return "Resource Packs"
|
||||||
|
case .dataPack:
|
||||||
|
return "Data Packs"
|
||||||
|
case .skinPack:
|
||||||
|
return "Skin Packs"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "World Templates"
|
||||||
|
case .shaderPack:
|
||||||
|
return "Shader Packs"
|
||||||
|
case .mod:
|
||||||
|
return "Mods"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
|
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case .world:
|
case .world:
|
||||||
@ -413,6 +453,27 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarIcon(for contentKind: MinecraftContentKind) -> String {
|
||||||
|
switch contentKind {
|
||||||
|
case .world:
|
||||||
|
return "globe.europe.africa"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "shippingbox"
|
||||||
|
case .resourcePack:
|
||||||
|
return "paintpalette"
|
||||||
|
case .dataPack:
|
||||||
|
return "curlybraces.square"
|
||||||
|
case .skinPack:
|
||||||
|
return "person.crop.square"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "map"
|
||||||
|
case .shaderPack:
|
||||||
|
return "camera.filters"
|
||||||
|
case .mod:
|
||||||
|
return "hammer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
|
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
|
||||||
Button("Share...") {
|
Button("Share...") {
|
||||||
@ -547,169 +608,23 @@ 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)
|
||||||
}
|
|
||||||
|
|
||||||
private func importIntoSource(_ source: MinecraftSource) {
|
|
||||||
guard source.availability == .available, source.capabilities.canInstallItems else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let panel = NSOpenPanel()
|
|
||||||
panel.allowsMultipleSelection = true
|
|
||||||
panel.canChooseDirectories = false
|
|
||||||
panel.canChooseFiles = true
|
|
||||||
panel.title = "Import Minecraft Content into \(source.displayName)"
|
|
||||||
panel.prompt = "Import"
|
|
||||||
panel.allowedContentTypes = [
|
|
||||||
.minecraftWorld,
|
|
||||||
.minecraftPack,
|
|
||||||
.minecraftTemplate,
|
|
||||||
.minecraftAddon
|
|
||||||
]
|
|
||||||
|
|
||||||
guard panel.runModal() == .OK else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await performImport(packageURLs: panel.urls, into: source.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleImportDrop(
|
|
||||||
on source: MinecraftSource,
|
|
||||||
providers: [NSItemProvider]
|
|
||||||
) -> Bool {
|
|
||||||
guard
|
|
||||||
source.availability == .available,
|
|
||||||
source.capabilities.canInstallItems,
|
|
||||||
providers.contains(where: isSupportedImportProvider(_:))
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
|
||||||
let materializedDrop = await materializeDroppedPackages(from: providers)
|
|
||||||
defer {
|
|
||||||
try? FileManager.default.removeItem(at: materializedDrop.temporaryRootURL)
|
|
||||||
}
|
}
|
||||||
guard !materializedDrop.packageURLs.isEmpty else {
|
|
||||||
await MainActor.run {
|
|
||||||
importErrorMessage = "The dropped items did not contain a supported .mcworld, .mcpack, .mctemplate, or .mcaddon file."
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await performImport(packageURLs: materializedDrop.packageURLs, into: source.id)
|
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private func addCandidateSource(_ candidate: SourceCandidate) {
|
||||||
private func performImport(packageURLs: [URL], into sourceID: URL) async {
|
Task {
|
||||||
do {
|
let sourceID = await library.addSource(candidate: candidate)
|
||||||
_ = try await library.installPackages(at: packageURLs, into: sourceID)
|
|
||||||
selectedSidebarSelection = .source(sourceID: sourceID)
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
} catch {
|
|
||||||
importErrorMessage = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSupportedImportProvider(_ provider: NSItemProvider) -> Bool {
|
private func revealCandidateInFinder(_ candidate: SourceCandidate) {
|
||||||
importTypeIdentifiers.contains {
|
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
|
||||||
provider.hasItemConformingToTypeIdentifier($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var importTypeIdentifiers: [String] {
|
|
||||||
[
|
|
||||||
UTType.minecraftWorld.identifier,
|
|
||||||
UTType.minecraftPack.identifier,
|
|
||||||
UTType.minecraftTemplate.identifier,
|
|
||||||
UTType.minecraftAddon.identifier,
|
|
||||||
UTType.fileURL.identifier
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func materializeDroppedPackages(
|
|
||||||
from providers: [NSItemProvider]
|
|
||||||
) async -> (packageURLs: [URL], temporaryRootURL: URL) {
|
|
||||||
let temporaryRootURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("MinecraftImportDrop", isDirectory: true)
|
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
var packageURLs: [URL] = []
|
|
||||||
for provider in providers where isSupportedImportProvider(provider) {
|
|
||||||
if let packageURL = await materializeDroppedPackage(
|
|
||||||
from: provider,
|
|
||||||
in: temporaryRootURL
|
|
||||||
) {
|
|
||||||
packageURLs.append(packageURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (packageURLs, temporaryRootURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func materializeDroppedPackage(
|
|
||||||
from provider: NSItemProvider,
|
|
||||||
in temporaryRootURL: URL
|
|
||||||
) async -> URL? {
|
|
||||||
let packageDefinitions = MinecraftPackageTypes.all
|
|
||||||
if let definition = packageDefinitions.first(where: {
|
|
||||||
provider.hasItemConformingToTypeIdentifier($0.utTypeIdentifier)
|
|
||||||
}) {
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
provider.loadFileRepresentation(forTypeIdentifier: definition.utTypeIdentifier) { url, _ in
|
|
||||||
guard let url else {
|
|
||||||
continuation.resume(returning: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let baseName = provider.suggestedName.map {
|
|
||||||
URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent
|
|
||||||
} ?? UUID().uuidString
|
|
||||||
let destinationURL = temporaryRootURL
|
|
||||||
.appendingPathComponent(baseName)
|
|
||||||
.appendingPathExtension(definition.pathExtension)
|
|
||||||
do {
|
|
||||||
try FileManager.default.copyItem(at: url, to: destinationURL)
|
|
||||||
continuation.resume(returning: destinationURL)
|
|
||||||
} catch {
|
|
||||||
continuation.resume(returning: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
|
|
||||||
guard
|
|
||||||
let data,
|
|
||||||
let sourceURL = NSURL(
|
|
||||||
absoluteURLWithDataRepresentation: data,
|
|
||||||
relativeTo: nil
|
|
||||||
) as URL?,
|
|
||||||
MinecraftPackageInspector.supportedPathExtensions.contains(
|
|
||||||
sourceURL.pathExtension.lowercased()
|
|
||||||
)
|
|
||||||
else {
|
|
||||||
continuation.resume(returning: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let destinationURL = temporaryRootURL.appendingPathComponent(sourceURL.lastPathComponent)
|
|
||||||
do {
|
|
||||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
|
||||||
continuation.resume(returning: destinationURL)
|
|
||||||
} catch {
|
|
||||||
continuation.resume(returning: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||||
@ -729,7 +644,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -771,8 +686,22 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func syncSelection(with sourceIDs: [URL]) {
|
private func syncSelection(with sourceIDs: [URL]) {
|
||||||
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
if let selectedSidebarSelection {
|
||||||
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
switch selectedSidebarSelection {
|
||||||
|
case .sourceCandidate(let candidateID):
|
||||||
|
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
|
||||||
|
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||||
|
}
|
||||||
|
case .connectedDevice(let deviceID):
|
||||||
|
if !library.sidebarConnectedDevices.contains(where: { $0.id == deviceID }) {
|
||||||
|
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||||
|
}
|
||||||
|
case .source, .allContent, .contentType, .contentKind:
|
||||||
|
if let selectedSourceID = selectedSidebarSelection.sourceID,
|
||||||
|
!sourceIDs.contains(selectedSourceID) {
|
||||||
|
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
|
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
|
||||||
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
|
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
|
||||||
}
|
}
|
||||||
@ -953,7 +882,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func archiveType(for item: MinecraftContentItem) -> UTType {
|
private func archiveType(for item: MinecraftContentItem) -> UTType {
|
||||||
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
|
itemActionService.archiveContentType(for: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {
|
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {
|
||||||
@ -995,8 +924,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView(
|
||||||
|
dependencies: .makePreview(),
|
||||||
|
initialSidebarSelection: .contentKind(
|
||||||
|
sourceID: PreviewFixtures.primarySource.id,
|
||||||
|
contentKind: .world
|
||||||
|
),
|
||||||
|
initialItemID: PreviewFixtures.featuredWorld.id
|
||||||
|
)
|
||||||
|
.frame(width: 1_440, height: 900)
|
||||||
|
.previewDisplayName("Full Window")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@ -77,21 +77,31 @@ enum ItemCollectionProjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .sourceCandidate:
|
||||||
|
return "Source Candidate"
|
||||||
|
case .connectedDevice:
|
||||||
|
return "Connected Device"
|
||||||
case .source, .allContent:
|
case .source, .allContent:
|
||||||
return "All Items"
|
return "All Items"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
|
case .contentKind(_, let contentKind):
|
||||||
|
return sidebarTitle(for: contentKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
|
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .some(.sourceCandidate), .some(.connectedDevice):
|
||||||
|
return "Search Library"
|
||||||
case .some(.source):
|
case .some(.source):
|
||||||
return "Search \(source?.displayName ?? "Library")"
|
return "Search \(source?.displayName ?? "Library")"
|
||||||
case .some(.allContent):
|
case .some(.allContent):
|
||||||
return "Search All Items"
|
return "Search All Items"
|
||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
return "Search \(sidebarTitle(for: contentType))"
|
return "Search \(sidebarTitle(for: contentType))"
|
||||||
|
case .some(.contentKind(_, let contentKind)):
|
||||||
|
return "Search \(sidebarTitle(for: contentKind))"
|
||||||
case .none:
|
case .none:
|
||||||
return "Search Library"
|
return "Search Library"
|
||||||
}
|
}
|
||||||
@ -99,12 +109,18 @@ enum ItemCollectionProjector {
|
|||||||
|
|
||||||
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
|
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .some(.sourceCandidate):
|
||||||
|
return "Source Candidate"
|
||||||
|
case .some(.connectedDevice):
|
||||||
|
return "Connected Device"
|
||||||
case .some(.source):
|
case .some(.source):
|
||||||
return "Library"
|
return "Library"
|
||||||
case .some(.allContent):
|
case .some(.allContent):
|
||||||
return "All"
|
return "All"
|
||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
|
case .some(.contentKind(_, let contentKind)):
|
||||||
|
return sidebarTitle(for: contentKind)
|
||||||
case .none:
|
case .none:
|
||||||
return "Library"
|
return "Library"
|
||||||
}
|
}
|
||||||
@ -116,6 +132,8 @@ enum ItemCollectionProjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .sourceCandidate, .connectedDevice:
|
||||||
|
return "items"
|
||||||
case .source, .allContent:
|
case .source, .allContent:
|
||||||
return scopedItemCount == 1 ? "item" : "items"
|
return scopedItemCount == 1 ? "item" : "items"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
@ -125,6 +143,17 @@ enum ItemCollectionProjector {
|
|||||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
return scopedItemCount == 1 ? "pack" : "packs"
|
return scopedItemCount == 1 ? "pack" : "packs"
|
||||||
}
|
}
|
||||||
|
case .contentKind(_, let contentKind):
|
||||||
|
switch contentKind {
|
||||||
|
case .world:
|
||||||
|
return scopedItemCount == 1 ? "world" : "worlds"
|
||||||
|
case .mod:
|
||||||
|
return scopedItemCount == 1 ? "mod" : "mods"
|
||||||
|
case .shaderPack:
|
||||||
|
return scopedItemCount == 1 ? "shader pack" : "shader packs"
|
||||||
|
case .behaviorPack, .resourcePack, .dataPack, .skinPack, .worldTemplate:
|
||||||
|
return scopedItemCount == 1 ? "pack" : "packs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,6 +172,27 @@ enum ItemCollectionProjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
|
||||||
|
switch contentKind {
|
||||||
|
case .world:
|
||||||
|
return "Worlds"
|
||||||
|
case .behaviorPack:
|
||||||
|
return "Behavior Packs"
|
||||||
|
case .resourcePack:
|
||||||
|
return "Resource Packs"
|
||||||
|
case .dataPack:
|
||||||
|
return "Data Packs"
|
||||||
|
case .skinPack:
|
||||||
|
return "Skin Packs"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "World Templates"
|
||||||
|
case .shaderPack:
|
||||||
|
return "Shader Packs"
|
||||||
|
case .mod:
|
||||||
|
return "Mods"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
|
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
|
||||||
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,6 +160,7 @@ private struct AppTransportBadgeBubbleModifier: ViewModifier {
|
|||||||
enum AppCapsuleLabelStyle {
|
enum AppCapsuleLabelStyle {
|
||||||
case sidebarSubtle
|
case sidebarSubtle
|
||||||
case sidebarAccent
|
case sidebarAccent
|
||||||
|
case sidebarSelected
|
||||||
case heroMetadata
|
case heroMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +182,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
|||||||
return AnyShapeStyle(.secondary)
|
return AnyShapeStyle(.secondary)
|
||||||
case .sidebarAccent:
|
case .sidebarAccent:
|
||||||
return AnyShapeStyle(Color.appAccent)
|
return AnyShapeStyle(Color.appAccent)
|
||||||
|
case .sidebarSelected:
|
||||||
|
return AnyShapeStyle(.white.opacity(0.92))
|
||||||
case .heroMetadata:
|
case .heroMetadata:
|
||||||
return AnyShapeStyle(.white.opacity(0.95))
|
return AnyShapeStyle(.white.opacity(0.95))
|
||||||
}
|
}
|
||||||
@ -192,6 +195,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
|||||||
return AnyShapeStyle(.secondary.opacity(0.12))
|
return AnyShapeStyle(.secondary.opacity(0.12))
|
||||||
case .sidebarAccent:
|
case .sidebarAccent:
|
||||||
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
||||||
|
case .sidebarSelected:
|
||||||
|
return AnyShapeStyle(.white.opacity(0.16))
|
||||||
case .heroMetadata:
|
case .heroMetadata:
|
||||||
return AnyShapeStyle(.white.opacity(0.14))
|
return AnyShapeStyle(.white.opacity(0.14))
|
||||||
}
|
}
|
||||||
@ -201,7 +206,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
|||||||
switch style {
|
switch style {
|
||||||
case .heroMetadata:
|
case .heroMetadata:
|
||||||
return 10
|
return 10
|
||||||
case .sidebarSubtle, .sidebarAccent:
|
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
|
||||||
return 7
|
return 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -210,7 +215,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
|||||||
switch style {
|
switch style {
|
||||||
case .heroMetadata:
|
case .heroMetadata:
|
||||||
return 7
|
return 7
|
||||||
case .sidebarSubtle, .sidebarAccent:
|
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
|
||||||
return 4
|
return 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,17 +2,21 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
enum SidebarSelection: Hashable, Sendable {
|
enum SidebarSelection: Hashable, Sendable {
|
||||||
case source(sourceID: URL)
|
case source(sourceID: URL)
|
||||||
|
case sourceCandidate(candidateID: String)
|
||||||
|
case connectedDevice(deviceID: String)
|
||||||
case allContent(sourceID: URL)
|
case allContent(sourceID: URL)
|
||||||
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||||
|
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
|
||||||
|
|
||||||
var sourceID: URL {
|
var sourceID: URL? {
|
||||||
switch self {
|
switch self {
|
||||||
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _):
|
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
|
||||||
return sourceID
|
return sourceID
|
||||||
|
case .sourceCandidate, .connectedDevice:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -25,44 +29,80 @@ struct SidebarFilter: Identifiable, Hashable {
|
|||||||
let selection: SidebarSelection
|
let selection: SidebarSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SidebarNode: Identifiable, Hashable {
|
||||||
|
let id: SidebarSelection
|
||||||
|
let row: SidebarNodeRow
|
||||||
|
let children: [SidebarNode]?
|
||||||
|
|
||||||
|
var selection: SidebarSelection { id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SidebarNodeRow: Hashable {
|
||||||
|
case source(MinecraftSource)
|
||||||
|
case filter(SidebarFilter)
|
||||||
|
case connectedDevice(ConnectedDeviceSidebarEntry)
|
||||||
|
case sourceCandidate(SourceCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
struct SourcesSidebarView: View {
|
struct SourcesSidebarView: View {
|
||||||
let sources: [MinecraftSource]
|
let sources: [MinecraftSource]
|
||||||
let connectedDevices: [ConnectedDeviceSidebarEntry]
|
let connectedDevices: [ConnectedDeviceSidebarEntry]
|
||||||
|
let sourceCandidates: [SourceCandidate]
|
||||||
|
let isDiscoveringSourceCandidates: Bool
|
||||||
@Binding var selection: SidebarSelection?
|
@Binding var selection: SidebarSelection?
|
||||||
let addSourceAction: () -> Void
|
let addSourceAction: () -> Void
|
||||||
|
let discoverSourcesAction: () -> Void
|
||||||
|
let addCandidateSourceAction: (SourceCandidate) -> Void
|
||||||
let addDeviceSourceAction: () -> Void
|
let addDeviceSourceAction: () -> Void
|
||||||
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||||
let rescanSourceAction: (MinecraftSource) -> Void
|
let rescanSourceAction: (MinecraftSource) -> Void
|
||||||
let removeSourceAction: (MinecraftSource) -> Void
|
let removeSourceAction: (MinecraftSource) -> Void
|
||||||
let importSourceAction: (MinecraftSource) -> Void
|
|
||||||
let importDropAction: (MinecraftSource, [NSItemProvider]) -> Bool
|
|
||||||
let installationState: (MinecraftSource) -> SourceInstallationState?
|
|
||||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
if !sources.isEmpty {
|
if !libraryNodes.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(sources) { source in
|
OutlineGroup(libraryNodes, children: \.children, content: sidebarNodeRow)
|
||||||
sourceSectionRows(for: source)
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
SidebarSourcesSectionHeaderView(title: "Libraries")
|
SidebarSourcesSectionHeaderView(title: "Libraries")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !connectedDevices.isEmpty {
|
if !deviceNodes.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(connectedDevices) { entry in
|
OutlineGroup(deviceNodes, children: \.children, content: sidebarNodeRow)
|
||||||
connectedDeviceSectionRows(for: entry)
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !candidateNodes.isEmpty {
|
||||||
|
Section {
|
||||||
|
OutlineGroup(candidateNodes, children: \.children, content: sidebarNodeRow)
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView(title: "Found Sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
.transaction { transaction in
|
||||||
|
transaction.animation = nil
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem {
|
||||||
|
Button(action: discoverSourcesAction) {
|
||||||
|
if isDiscoveringSourceCandidates {
|
||||||
|
ProgressView()
|
||||||
|
.appActivityIndicatorStyle(.small)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isDiscoveringSourceCandidates)
|
||||||
|
.help("Find Minecraft Sources")
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
Button(action: addSourceAction) {
|
Button(action: addSourceAction) {
|
||||||
Image(systemName: "folder.badge.plus")
|
Image(systemName: "folder.badge.plus")
|
||||||
@ -79,77 +119,151 @@ struct SourcesSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var libraryNodes: [SidebarNode] {
|
||||||
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
sources.map { source in
|
||||||
let sourceFilters = filters(source)
|
let childNodes = filters(source).map { filter in
|
||||||
|
SidebarNode(
|
||||||
SourceHeaderRow(
|
id: filter.selection,
|
||||||
source: source,
|
row: .filter(filter),
|
||||||
installationState: installationState(source),
|
children: nil
|
||||||
onSelect: {
|
)
|
||||||
selection = .source(sourceID: source.id)
|
|
||||||
},
|
|
||||||
dropAction: { providers in
|
|
||||||
importDropAction(source, providers)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
|
|
||||||
.contextMenu {
|
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
|
||||||
rescanSourceAction(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Import into \"\(source.displayName)\"...") {
|
|
||||||
importSourceAction(source)
|
|
||||||
}
|
|
||||||
.disabled(source.availability != .available || !source.capabilities.canInstallItems)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
|
||||||
removeSourceAction(source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(sourceFilters) { filter in
|
return SidebarNode(
|
||||||
SidebarFilterRow(filter: filter, isIndented: true)
|
id: .source(sourceID: source.id),
|
||||||
.tag(filter.selection as SidebarSelection?)
|
row: .source(source),
|
||||||
|
children: childNodes.isEmpty ? nil : childNodes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deviceNodes: [SidebarNode] {
|
||||||
|
connectedDevices.map { entry in
|
||||||
|
SidebarNode(
|
||||||
|
id: .connectedDevice(deviceID: entry.id),
|
||||||
|
row: .connectedDevice(entry),
|
||||||
|
children: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var candidateNodes: [SidebarNode] {
|
||||||
|
sourceCandidates.map { candidate in
|
||||||
|
SidebarNode(
|
||||||
|
id: .sourceCandidate(candidateID: candidate.id),
|
||||||
|
row: .sourceCandidate(candidate),
|
||||||
|
children: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
|
||||||
ConnectedDeviceRow(
|
switch node.row {
|
||||||
entry: entry,
|
case .source(let source):
|
||||||
addAction: entry.hasMinecraftContainer ? {
|
SourceHeaderRow(source: source, isSelected: selection == node.selection)
|
||||||
addConnectedDeviceAction(entry)
|
.tag(node.selection as SidebarSelection?)
|
||||||
} : nil
|
.listRowSeparator(.hidden)
|
||||||
)
|
.contextMenu {
|
||||||
.listRowSeparator(.hidden)
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
rescanSourceAction(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
|
removeSourceAction(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .filter(let filter):
|
||||||
|
SidebarFilterRow(filter: filter)
|
||||||
|
.tag(node.selection as SidebarSelection?)
|
||||||
|
case .connectedDevice(let entry):
|
||||||
|
ConnectedDeviceRow(
|
||||||
|
entry: entry,
|
||||||
|
addAction: entry.hasMinecraftContainer ? {
|
||||||
|
addConnectedDeviceAction(entry)
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
.tag(node.selection as SidebarSelection?)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
case .sourceCandidate(let candidate):
|
||||||
|
SourceCandidateRow(
|
||||||
|
candidate: candidate,
|
||||||
|
addAction: {
|
||||||
|
addCandidateSourceAction(candidate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.tag(node.selection as SidebarSelection?)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SourceCandidateRow: View {
|
||||||
|
let candidate: SourceCandidate
|
||||||
|
let addAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(candidate.displayName)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Button(action: addAction) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
.appMiniProminentButton()
|
||||||
|
.help("Add Source")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbolName: String {
|
||||||
|
switch candidate.edition {
|
||||||
|
case .bedrock:
|
||||||
|
return "folder"
|
||||||
|
case .java:
|
||||||
|
return "curlybraces"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
let editionName = candidate.edition == .java ? "Java" : "Bedrock"
|
||||||
|
return "\(editionName) - \(candidate.sourceRootURL.lastPathComponent)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SidebarFilterRow: View {
|
private struct SidebarFilterRow: View {
|
||||||
let filter: SidebarFilter
|
let filter: SidebarFilter
|
||||||
let isIndented: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack {
|
||||||
Image(systemName: filter.iconName)
|
Label {
|
||||||
.frame(width: 16)
|
Text(filter.title)
|
||||||
.foregroundStyle(.secondary)
|
} icon: {
|
||||||
|
Image(systemName: filter.iconName)
|
||||||
Text(filter.title)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(filter.count, format: .number)
|
Text(filter.count, format: .number)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, isIndented ? 16 : 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,28 +277,31 @@ private struct SidebarSourcesSectionHeaderView: View {
|
|||||||
|
|
||||||
private struct SourceHeaderRow: View {
|
private struct SourceHeaderRow: View {
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
let installationState: SourceInstallationState?
|
let isSelected: Bool
|
||||||
let onSelect: () -> Void
|
|
||||||
let dropAction: ([NSItemProvider]) -> Bool
|
|
||||||
@State private var isDropTargeted = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack {
|
||||||
Image(systemName: headerSymbolName)
|
Label {
|
||||||
.foregroundStyle(.secondary)
|
Text(source.displayName)
|
||||||
|
.lineLimit(1)
|
||||||
Text(source.displayName)
|
} icon: {
|
||||||
.lineLimit(1)
|
Image(systemName: headerSymbolName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let availabilityBadgeText {
|
if let availabilityBadgeText {
|
||||||
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
|
SourceAvailabilityBadge(
|
||||||
|
text: availabilityBadgeText,
|
||||||
|
emphasis: availabilityBadgeEmphasis,
|
||||||
|
isSelected: isSelected
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let connection {
|
if let connection {
|
||||||
SourceConnectionBadge(connection: connection)
|
SourceConnectionBadge(connection: connection, isSelected: isSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
if showsStatusAccessory {
|
if showsStatusAccessory {
|
||||||
@ -193,25 +310,6 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.leading, 5)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
isDropTargeted ? Color.accentColor.opacity(0.16) : Color.clear,
|
|
||||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture(perform: onSelect)
|
|
||||||
.onDrop(
|
|
||||||
of: [
|
|
||||||
UTType.fileURL.identifier,
|
|
||||||
UTType.minecraftWorld.identifier,
|
|
||||||
UTType.minecraftPack.identifier,
|
|
||||||
UTType.minecraftTemplate.identifier,
|
|
||||||
UTType.minecraftAddon.identifier
|
|
||||||
],
|
|
||||||
isTargeted: $isDropTargeted,
|
|
||||||
perform: dropAction
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connection: DeviceConnection? {
|
private var connection: DeviceConnection? {
|
||||||
@ -228,7 +326,7 @@ private struct SourceHeaderRow: View {
|
|||||||
|
|
||||||
private var headerSymbolName: String {
|
private var headerSymbolName: String {
|
||||||
switch source.origin {
|
switch source.origin {
|
||||||
case .localFolder:
|
case .localFolder, .javaLocalFolder:
|
||||||
return "folder"
|
return "folder"
|
||||||
case .connectedDevice:
|
case .connectedDevice:
|
||||||
return "iphone.gen3"
|
return "iphone.gen3"
|
||||||
@ -255,39 +353,29 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var showsStatusAccessory: Bool {
|
private var showsStatusAccessory: Bool {
|
||||||
source.isScanning || installationState != nil
|
source.isScanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusAccessory: some View {
|
private var statusAccessory: some View {
|
||||||
if let installationState {
|
if source.isScanning {
|
||||||
if installationState.totalCount > 0 {
|
if let scanProgress = source.scanProgress {
|
||||||
CircularScanProgressView(
|
CircularScanProgressView(progress: scanProgress, isSelected: isSelected)
|
||||||
progress: Double(installationState.completedCount) / Double(installationState.totalCount)
|
} else {
|
||||||
)
|
ProgressView()
|
||||||
.help(installationState.status)
|
.appActivityIndicatorStyle(.small)
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.appActivityIndicatorStyle(.small)
|
|
||||||
.help(installationState.status)
|
|
||||||
}
|
|
||||||
} else if source.isScanning {
|
|
||||||
if let scanProgress = source.scanProgress {
|
|
||||||
CircularScanProgressView(progress: scanProgress)
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.appActivityIndicatorStyle(.small)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SourceConnectionBadge: View {
|
private struct SourceConnectionBadge: View {
|
||||||
let connection: DeviceConnection
|
let connection: DeviceConnection
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image(systemName: symbolName)
|
Image(systemName: symbolName)
|
||||||
.appCapsuleLabelStyle(.sidebarSubtle)
|
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : .sidebarSubtle)
|
||||||
.help(helpText)
|
.help(helpText)
|
||||||
.accessibilityLabel(helpText)
|
.accessibilityLabel(helpText)
|
||||||
}
|
}
|
||||||
@ -314,30 +402,35 @@ private struct SourceConnectionBadge: View {
|
|||||||
private struct SourceAvailabilityBadge: View {
|
private struct SourceAvailabilityBadge: View {
|
||||||
let text: String
|
let text: String
|
||||||
let emphasis: Bool
|
let emphasis: Bool
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
|
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : emphasis ? .sidebarAccent : .sidebarSubtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CircularScanProgressView: View {
|
private struct CircularScanProgressView: View {
|
||||||
let progress: Double
|
let progress: Double
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
private let size: CGFloat = 17
|
||||||
|
private let lineWidth: CGFloat = 1.4
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(.secondary.opacity(0.18), lineWidth: 3)
|
.stroke(isSelected ? .white.opacity(0.18) : Color.secondary.opacity(0.24), lineWidth: lineWidth)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: max(0.02, min(progress, 1)))
|
.trim(from: 0, to: max(0, min(progress, 1)))
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.appAccent,
|
isSelected ? .white.opacity(0.86) : Color.appAccent,
|
||||||
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
}
|
}
|
||||||
.frame(width: 18, height: 18)
|
.frame(width: size, height: size)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel("Scan progress")
|
.accessibilityLabel("Scan progress")
|
||||||
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
|
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
|
||||||
@ -349,20 +442,22 @@ private struct ConnectedDeviceRow: View {
|
|||||||
let addAction: (() -> Void)?
|
let addAction: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
HStack(alignment: .top) {
|
||||||
ConnectedDeviceTransportIcon(
|
Label {
|
||||||
baseSymbolName: iconName,
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
connection: entry.device.connection,
|
Text(entry.device.name)
|
||||||
tint: iconColor
|
.appTextStyle(.rowTitle)
|
||||||
)
|
.foregroundStyle(titleColor)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text(statusText)
|
||||||
Text(entry.device.name)
|
.appTextStyle(.supportingCompact)
|
||||||
.appTextStyle(.rowTitle)
|
}
|
||||||
.foregroundStyle(titleColor)
|
} icon: {
|
||||||
|
ConnectedDeviceTransportIcon(
|
||||||
Text(statusText)
|
baseSymbolName: iconName,
|
||||||
.appTextStyle(.supportingCompact)
|
connection: entry.device.connection,
|
||||||
|
tint: iconColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
|
|||||||
@ -13,6 +13,9 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
@Test func sourceOriginsExposeOutboundCapabilities() async throws {
|
@Test func sourceOriginsExposeOutboundCapabilities() async throws {
|
||||||
let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local"))
|
let localSource = MinecraftSource(folderURL: URL(fileURLWithPath: "/tmp/local"))
|
||||||
#expect(localSource.capabilities == .localFolder)
|
#expect(localSource.capabilities == .localFolder)
|
||||||
|
#expect(localSource.edition == .bedrock)
|
||||||
|
#expect(localSource.providerID == LocalFolderSourceAccess().accessorIdentifier)
|
||||||
|
#expect(localSource.accessStatus.mode == .localFileSystem)
|
||||||
|
|
||||||
let device = ConnectedDevice(
|
let device = ConnectedDevice(
|
||||||
udid: "device",
|
udid: "device",
|
||||||
@ -35,6 +38,556 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#expect(deviceSource.capabilities == .connectedDevice)
|
#expect(deviceSource.capabilities == .connectedDevice)
|
||||||
|
#expect(deviceSource.edition == .bedrock)
|
||||||
|
#expect(deviceSource.providerID == AppleMobileDeviceSourceAccess().accessorIdentifier)
|
||||||
|
#expect(deviceSource.accessStatus.mode == .usbDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func contentItemsExposeNeutralProviderSurface() async throws {
|
||||||
|
let rootURL = URL(fileURLWithPath: "/tmp/source")
|
||||||
|
let item = MinecraftContentItem(
|
||||||
|
folderURL: rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true),
|
||||||
|
folderName: "WorldA",
|
||||||
|
contentType: .world,
|
||||||
|
collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true),
|
||||||
|
displayName: "World A",
|
||||||
|
packUUID: "ABC-123",
|
||||||
|
packVersion: "1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(item.sourceEdition == .bedrock)
|
||||||
|
#expect(item.contentKind == .world)
|
||||||
|
#expect(item.platformType == .bedrock(.world))
|
||||||
|
#expect(item.capabilities.portablePackageExtension == "mcworld")
|
||||||
|
if case .bedrock(let metadata) = item.platformMetadata {
|
||||||
|
#expect(metadata.packUUID == "abc-123")
|
||||||
|
#expect(metadata.packVersion == "1.0.0")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected Bedrock metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bedrockCompatibilityFieldsSynchronizePlatformMetadata() async throws {
|
||||||
|
let rootURL = URL(fileURLWithPath: "/tmp/source")
|
||||||
|
var item = MinecraftContentItem(
|
||||||
|
folderURL: rootURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true),
|
||||||
|
folderName: "PackA",
|
||||||
|
contentType: .behaviorPack,
|
||||||
|
collectionRootURL: rootURL.appendingPathComponent("behavior_packs", isDirectory: true)
|
||||||
|
)
|
||||||
|
|
||||||
|
item.packUUID = "PACK-A"
|
||||||
|
item.packVersion = "2.0.0"
|
||||||
|
|
||||||
|
if case .bedrock(let metadata) = item.platformMetadata {
|
||||||
|
#expect(metadata.packUUID == "pack-a")
|
||||||
|
#expect(metadata.packVersion == "2.0.0")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected Bedrock metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func localFolderAccessStreamsProviderEvents() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: rootURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: itemURL, withIntermediateDirectories: true)
|
||||||
|
try "World A".write(
|
||||||
|
to: itemURL.appendingPathComponent("levelname.txt"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
let source = MinecraftSource(folderURL: rootURL)
|
||||||
|
let access = LocalFolderSourceAccess()
|
||||||
|
var sawAccessStatus = false
|
||||||
|
var sawRunningStage = false
|
||||||
|
var sawFinishedStage = false
|
||||||
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
|
||||||
|
for try await event in access.scanEvents(for: source, mode: .fullScan) {
|
||||||
|
switch event {
|
||||||
|
case .accessStatusChanged(let status):
|
||||||
|
sawAccessStatus = true
|
||||||
|
#expect(status.availability == .available)
|
||||||
|
#expect(status.mode == .localFileSystem)
|
||||||
|
case .stageUpdated(let stage):
|
||||||
|
if stage.state == .running {
|
||||||
|
sawRunningStage = true
|
||||||
|
}
|
||||||
|
if stage.state == .succeeded {
|
||||||
|
sawFinishedStage = true
|
||||||
|
}
|
||||||
|
case .discovered(let item):
|
||||||
|
discoveredItems.append(item)
|
||||||
|
case .inspected, .warning:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(sawAccessStatus)
|
||||||
|
#expect(sawRunningStage)
|
||||||
|
#expect(sawFinishedStage)
|
||||||
|
#expect(discoveredItems.map(\.displayName).contains("WorldA"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaLocalFolderSourceUsesJavaProviderDefaults() async throws {
|
||||||
|
let source = MinecraftSource(
|
||||||
|
folderURL: URL(fileURLWithPath: "/tmp/java"),
|
||||||
|
origin: .javaLocalFolder(bookmarkData: nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(source.edition == .java)
|
||||||
|
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
|
||||||
|
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
|
||||||
|
#expect(source.accessStatus.mode == .localFileSystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaLocalFolderAccessDiscoversWorldsAndResourcePacks() 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 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)
|
||||||
|
try Data().write(to: worldURL.appendingPathComponent("level.dat"))
|
||||||
|
try "Displayed Java World".write(
|
||||||
|
to: worldURL.appendingPathComponent("levelname.txt"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true)
|
||||||
|
try "{}".write(
|
||||||
|
to: packURL.appendingPathComponent("pack.mcmeta"),
|
||||||
|
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 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) {
|
||||||
|
if case .discovered(let item) = event {
|
||||||
|
discoveredItems.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var enrichedItems: [MinecraftContentItem] = []
|
||||||
|
for item in discoveredItems {
|
||||||
|
enrichedItems.append(await access.enrich(item, for: source))
|
||||||
|
}
|
||||||
|
|
||||||
|
#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] == 2)
|
||||||
|
#expect(index.displayItemCountsByKind[.shaderPack] == 1)
|
||||||
|
#expect(index.displayItemCountsByKind[.mod] == 1)
|
||||||
|
|
||||||
|
indexedSource.rawItems = enrichedItems
|
||||||
|
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"
|
||||||
|
version = "1.2.3"
|
||||||
|
displayName = "Example Java Mod"
|
||||||
|
logoFile = "icon.png"
|
||||||
|
authors = "Alex, Sam"
|
||||||
|
description = "A test mod."
|
||||||
|
|
||||||
|
[[dependencies.examplemod]]
|
||||||
|
modId = "minecraft"
|
||||||
|
versionRange = "[1.21,)"
|
||||||
|
""".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,
|
||||||
|
"supported_formats": {
|
||||||
|
"min_inclusive": 34,
|
||||||
|
"max_inclusive": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".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)
|
||||||
|
#expect(metadata.mod?.modID == "examplemod")
|
||||||
|
#expect(metadata.mod?.version == "1.2.3")
|
||||||
|
#expect(metadata.mod?.description == "A test mod.")
|
||||||
|
#expect(metadata.mod?.authors == ["Alex", "Sam"])
|
||||||
|
#expect(metadata.mod?.minecraftVersionRequirement == "[1.21,)")
|
||||||
|
} 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)
|
||||||
|
#expect(metadata.pack?.supportedFormats == "34-42")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected Java metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaProviderDiscoversSourceCandidatesFromBoundedRoots() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let instanceRootURL = workingURL
|
||||||
|
.appendingPathComponent("PrismLauncher/instances/Example Instance/.minecraft", isDirectory: true)
|
||||||
|
let modURL = instanceRootURL.appendingPathComponent("mods/ExampleMod.jar")
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("jar".utf8).write(to: modURL)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: instanceRootURL.appendingPathComponent("resourcepacks", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let access = JavaLocalFolderSourceAccess(candidateDiscoveryRoots: [workingURL])
|
||||||
|
var candidates: [SourceCandidate] = []
|
||||||
|
|
||||||
|
for try await event in access.discoverSourceCandidates() {
|
||||||
|
if case .candidate(let candidate) = event {
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(candidates.contains { candidate in
|
||||||
|
candidate.providerID == JavaLocalFolderSourceAccess().accessorIdentifier
|
||||||
|
&& candidate.edition == .java
|
||||||
|
&& candidate.sourceRootURL == instanceRootURL.standardizedFileURL
|
||||||
|
&& candidate.detectedKinds.contains(.mod)
|
||||||
|
&& candidate.detectedKinds.contains(.resourcePack)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaProviderCollapsesNestedSourceCandidatesToSearchRoot() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
|
||||||
|
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
for instanceURL in [firstInstanceURL, secondInstanceURL] {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = JavaContentScanner.discoverSourceCandidates(
|
||||||
|
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
searchRoots: [workingURL]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(candidates.count == 1)
|
||||||
|
#expect(candidates.first?.sourceRootURL == workingURL.standardizedFileURL)
|
||||||
|
#expect(candidates.first?.displayName == workingURL.lastPathComponent)
|
||||||
|
#expect(candidates.first?.detectedKinds.contains(.mod) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaProviderDeduplicatesCaseVariantSourceRoots() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let upperRootURL = workingURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true)
|
||||||
|
let lowerRootURL = workingURL.appendingPathComponent("curseforge/minecraft", isDirectory: true)
|
||||||
|
let firstInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleOne", isDirectory: true)
|
||||||
|
let secondInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleTwo", isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
for instanceURL in [firstInstanceURL, secondInstanceURL] {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: lowerRootURL.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = JavaContentScanner.discoverSourceCandidates(
|
||||||
|
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
searchRoots: [upperRootURL, lowerRootURL]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(candidates.count == 1)
|
||||||
|
#expect(sourceIdentityKey(for: candidates[0].sourceRootURL) == sourceIdentityKey(for: upperRootURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func javaAggregateRootDiscoversNestedInstanceItems() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let firstInstanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
|
||||||
|
let secondInstanceURL = workingURL.appendingPathComponent("a/e/f", isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: firstInstanceURL.appendingPathComponent("mods", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("jar".utf8).write(to: firstInstanceURL.appendingPathComponent("mods/ExampleMod.jar"))
|
||||||
|
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: secondInstanceURL.appendingPathComponent("resourcepacks", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("zip".utf8).write(to: secondInstanceURL.appendingPathComponent("resourcepacks/ExamplePack.zip"))
|
||||||
|
|
||||||
|
let items = try JavaContentScanner.discoverItems(in: workingURL)
|
||||||
|
let snapshots = JavaContentScanner.collectionSnapshots(in: workingURL)
|
||||||
|
|
||||||
|
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
|
||||||
|
#expect(items.contains { $0.contentKind == .resourcePack && $0.folderName == "ExamplePack.zip" })
|
||||||
|
#expect(snapshots.map(\.folderName).contains("a/b/c/mods"))
|
||||||
|
#expect(snapshots.map(\.folderName).contains("a/e/f/resourcepacks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sourceRestorationComparesDuplicateCollectionNamesWithoutCrashing() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let collectionSnapshots = [
|
||||||
|
CollectionSnapshot(
|
||||||
|
folderName: "mods",
|
||||||
|
modifiedDate: Date(timeIntervalSince1970: 100),
|
||||||
|
childDirectoryCount: 1,
|
||||||
|
fingerprint: "a/b/c/mods::1::100"
|
||||||
|
),
|
||||||
|
CollectionSnapshot(
|
||||||
|
folderName: "mods",
|
||||||
|
modifiedDate: Date(timeIntervalSince1970: 200),
|
||||||
|
childDirectoryCount: 2,
|
||||||
|
fingerprint: "x/y/z/mods::2::200"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
var source = MinecraftSource(
|
||||||
|
folderURL: workingURL,
|
||||||
|
origin: .javaLocalFolder(bookmarkData: nil),
|
||||||
|
accessDescriptor: SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
kind: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
),
|
||||||
|
availability: .available
|
||||||
|
)
|
||||||
|
source.edition = .java
|
||||||
|
source.snapshot = SourceSnapshot(
|
||||||
|
sourceID: workingURL,
|
||||||
|
rootModifiedDate: nil,
|
||||||
|
collectionSnapshots: collectionSnapshots,
|
||||||
|
itemSnapshots: []
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(SourceRestoration.needsReconcile(source) { _, _ in collectionSnapshots } == false)
|
||||||
|
#expect(
|
||||||
|
SourceRestoration.needsReconcile(source) { _, _ in
|
||||||
|
[
|
||||||
|
collectionSnapshots[0],
|
||||||
|
CollectionSnapshot(
|
||||||
|
folderName: "mods",
|
||||||
|
modifiedDate: Date(timeIntervalSince1970: 300),
|
||||||
|
childDirectoryCount: 3,
|
||||||
|
fingerprint: "x/y/z/mods::3::300"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
} == true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sourceLibraryAddSourceCandidatePreservesJavaAggregateProvider() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let instanceURL = workingURL.appendingPathComponent("a/b/c", isDirectory: true)
|
||||||
|
let modURL = instanceURL.appendingPathComponent("mods/ExampleMod.jar")
|
||||||
|
defer { try? fileManager.removeItem(at: workingURL) }
|
||||||
|
|
||||||
|
try fileManager.createDirectory(at: modURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("jar".utf8).write(to: modURL)
|
||||||
|
|
||||||
|
let candidate = SourceCandidate(
|
||||||
|
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
edition: .java,
|
||||||
|
sourceRootURL: workingURL,
|
||||||
|
displayName: workingURL.lastPathComponent,
|
||||||
|
confidence: .strong,
|
||||||
|
reason: "Found multiple Java sources",
|
||||||
|
detectedKinds: [.mod]
|
||||||
|
)
|
||||||
|
let access = SourceAccessCoordinator(
|
||||||
|
accessMethods: [
|
||||||
|
LocalFolderSourceAccess(),
|
||||||
|
JavaLocalFolderSourceAccess()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let library = SourceLibrary(sourceAccessMethod: access)
|
||||||
|
|
||||||
|
let sourceID = await library.addSource(candidate: candidate)
|
||||||
|
guard let source = library.source(withID: sourceID) else {
|
||||||
|
Issue.record("Expected added source")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(source.folderURL == workingURL.standardizedFileURL)
|
||||||
|
#expect(source.edition == .java)
|
||||||
|
#expect(source.providerID == JavaLocalFolderSourceAccess().accessorIdentifier)
|
||||||
|
#expect(source.accessDescriptor.accessorIdentifier == JavaLocalFolderSourceAccess().accessorIdentifier)
|
||||||
|
|
||||||
|
let items = try JavaContentScanner.discoverItems(in: source.folderURL)
|
||||||
|
#expect(items.contains { $0.contentKind == .mod && $0.folderName == "ExampleMod.jar" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws {
|
||||||
|
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 {
|
||||||
@ -771,119 +1324,6 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func packageInstallerPreparesCompleteWorldContents() async throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
let worldURL = workingURL.appendingPathComponent("World", isDirectory: true)
|
|
||||||
let archiveURL = workingURL.appendingPathComponent("World.mcworld", isDirectory: false)
|
|
||||||
defer { try? fileManager.removeItem(at: workingURL) }
|
|
||||||
|
|
||||||
try fileManager.createDirectory(
|
|
||||||
at: worldURL.appendingPathComponent("db", isDirectory: true),
|
|
||||||
withIntermediateDirectories: true
|
|
||||||
)
|
|
||||||
try Data([1, 2, 3]).write(to: worldURL.appendingPathComponent("level.dat"))
|
|
||||||
try Data([4, 5, 6]).write(to: worldURL.appendingPathComponent("db/chunk.bin"))
|
|
||||||
try makeArchive(from: worldURL, to: archiveURL)
|
|
||||||
|
|
||||||
let plan = try MinecraftPackageInstaller.preparePackage(at: archiveURL)
|
|
||||||
defer { plan.cleanup() }
|
|
||||||
|
|
||||||
#expect(plan.payloads.count == 1)
|
|
||||||
#expect(plan.payloads[0].contentType == .world)
|
|
||||||
#expect(
|
|
||||||
fileManager.fileExists(
|
|
||||||
atPath: plan.payloads[0].preparedDirectoryURL.appendingPathComponent("db/chunk.bin").path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func packageInstallerExpandsNestedAddonPackages() async throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
let behaviorURL = workingURL.appendingPathComponent("Behavior", isDirectory: true)
|
|
||||||
let resourceURL = workingURL.appendingPathComponent("Resource", isDirectory: true)
|
|
||||||
let addonRootURL = workingURL.appendingPathComponent("Addon", isDirectory: true)
|
|
||||||
let behaviorArchiveURL = addonRootURL.appendingPathComponent("Behavior.mcpack")
|
|
||||||
let resourceArchiveURL = addonRootURL.appendingPathComponent("Resource.mcpack")
|
|
||||||
let addonURL = workingURL.appendingPathComponent("Combined.mcaddon")
|
|
||||||
defer { try? fileManager.removeItem(at: workingURL) }
|
|
||||||
|
|
||||||
try fileManager.createDirectory(at: behaviorURL, withIntermediateDirectories: true)
|
|
||||||
try fileManager.createDirectory(at: resourceURL, withIntermediateDirectories: true)
|
|
||||||
try fileManager.createDirectory(at: addonRootURL, withIntermediateDirectories: true)
|
|
||||||
try testPackManifest(name: "Behavior", uuid: "11111111-1111-1111-1111-111111111111", moduleType: "data")
|
|
||||||
.write(to: behaviorURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
|
||||||
try testPackManifest(name: "Resource", uuid: "22222222-2222-2222-2222-222222222222", moduleType: "resources")
|
|
||||||
.write(to: resourceURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8)
|
|
||||||
try makeArchive(from: behaviorURL, to: behaviorArchiveURL)
|
|
||||||
try makeArchive(from: resourceURL, to: resourceArchiveURL)
|
|
||||||
try makeArchive(from: addonRootURL, to: addonURL)
|
|
||||||
|
|
||||||
let plan = try MinecraftPackageInstaller.preparePackage(at: addonURL)
|
|
||||||
defer { plan.cleanup() }
|
|
||||||
|
|
||||||
#expect(Set(plan.payloads.map(\.contentType)) == [.behaviorPack, .resourcePack])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func localFolderInstallerUsesUniqueAdditiveDestinations() async throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
let sourceWorldURL = workingURL.appendingPathComponent("PreparedWorld", isDirectory: true)
|
|
||||||
let destinationRootURL = workingURL.appendingPathComponent("Destination", isDirectory: true)
|
|
||||||
defer { try? fileManager.removeItem(at: workingURL) }
|
|
||||||
|
|
||||||
try fileManager.createDirectory(at: sourceWorldURL, withIntermediateDirectories: true)
|
|
||||||
try fileManager.createDirectory(at: destinationRootURL, withIntermediateDirectories: true)
|
|
||||||
try Data([1]).write(to: sourceWorldURL.appendingPathComponent("level.dat"))
|
|
||||||
|
|
||||||
let plan = try MinecraftPackageInstaller.prepareDirectory(at: sourceWorldURL, contentType: .world)
|
|
||||||
defer { plan.cleanup() }
|
|
||||||
let source = MinecraftSource(folderURL: destinationRootURL, availability: .available)
|
|
||||||
let access = LocalFolderSourceAccess()
|
|
||||||
|
|
||||||
let first = try await access.install(plan.payloads[0], in: source)
|
|
||||||
let second = try await access.install(plan.payloads[0], in: source)
|
|
||||||
|
|
||||||
#expect(first.destinationName != second.destinationName)
|
|
||||||
#expect(
|
|
||||||
fileManager.fileExists(
|
|
||||||
atPath: destinationRootURL
|
|
||||||
.appendingPathComponent("minecraftWorlds/\(first.destinationName)/level.dat")
|
|
||||||
.path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
#expect(
|
|
||||||
fileManager.fileExists(
|
|
||||||
atPath: destinationRootURL
|
|
||||||
.appendingPathComponent("minecraftWorlds/\(second.destinationName)/level.dat")
|
|
||||||
.path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func zipReaderRejectsParentTraversalEntry() async throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
let archiveURL = workingURL.appendingPathComponent("Unsafe.mcworld")
|
|
||||||
defer { try? fileManager.removeItem(at: workingURL) }
|
|
||||||
|
|
||||||
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
|
|
||||||
try makeStoredArchive(entries: [("../escape.txt", Data("escape".utf8))], to: archiveURL)
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try ZipArchiveReader(url: archiveURL)
|
|
||||||
Issue.record("Expected the unsafe archive path to be rejected.")
|
|
||||||
} catch let error as ZipArchiveReaderError {
|
|
||||||
switch error {
|
|
||||||
case .unsafeEntryPath(let path):
|
|
||||||
#expect(path == "../escape.txt")
|
|
||||||
default:
|
|
||||||
Issue.record("Expected unsafeEntryPath but received \(error).")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
@ -1064,6 +1504,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",
|
||||||
@ -1235,24 +1775,6 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
|
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func liveScanStatusCountsItemsRemainingDuringSizeCalculation() async throws {
|
|
||||||
var source = MinecraftSource(
|
|
||||||
folderURL: URL(fileURLWithPath: "/tmp/test-source", isDirectory: true)
|
|
||||||
)
|
|
||||||
source.displayName = "Test Source"
|
|
||||||
source.isScanning = true
|
|
||||||
source.indexedItemCount = 22
|
|
||||||
source.previewLoadedCount = 22
|
|
||||||
source.sizeLoadedCount = 12
|
|
||||||
source.scanProgress = 0.88
|
|
||||||
source.scanStatus = "Calculating sizes"
|
|
||||||
|
|
||||||
#expect(
|
|
||||||
SourcePresentation.liveScanStatusTitle(for: source)
|
|
||||||
== "Calculating sizes for 10 of 22 items..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
|
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
|
||||||
let service = ScanNotificationService()
|
let service = ScanNotificationService()
|
||||||
|
|
||||||
@ -1382,82 +1904,6 @@ private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throw
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func testPackManifest(name: String, uuid: String, moduleType: String) -> String {
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"format_version": 2,
|
|
||||||
"header": {
|
|
||||||
"name": "\(name)",
|
|
||||||
"description": "\(name)",
|
|
||||||
"uuid": "\(uuid)",
|
|
||||||
"version": [1, 0, 0]
|
|
||||||
},
|
|
||||||
"modules": [
|
|
||||||
{
|
|
||||||
"type": "\(moduleType)",
|
|
||||||
"uuid": "\(UUID().uuidString)",
|
|
||||||
"version": [1, 0, 0]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeStoredArchive(entries: [(String, Data)], to archiveURL: URL) throws {
|
|
||||||
var archive = Data()
|
|
||||||
var centralDirectory = Data()
|
|
||||||
|
|
||||||
for (path, contents) in entries {
|
|
||||||
let pathData = Data(path.utf8)
|
|
||||||
let localHeaderOffset = UInt32(archive.count)
|
|
||||||
|
|
||||||
appendLE(UInt32(0x04034b50), to: &archive)
|
|
||||||
appendLE(UInt16(20), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt32(0), to: &archive)
|
|
||||||
appendLE(UInt32(contents.count), to: &archive)
|
|
||||||
appendLE(UInt32(contents.count), to: &archive)
|
|
||||||
appendLE(UInt16(pathData.count), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
archive.append(pathData)
|
|
||||||
archive.append(contents)
|
|
||||||
|
|
||||||
appendLE(UInt32(0x02014b50), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(20), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(20), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt32(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt32(contents.count), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(pathData.count), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt16(0), to: ¢ralDirectory)
|
|
||||||
appendLE(UInt32(0), to: ¢ralDirectory)
|
|
||||||
appendLE(localHeaderOffset, to: ¢ralDirectory)
|
|
||||||
centralDirectory.append(pathData)
|
|
||||||
}
|
|
||||||
|
|
||||||
let centralDirectoryOffset = UInt32(archive.count)
|
|
||||||
archive.append(centralDirectory)
|
|
||||||
appendLE(UInt32(0x06054b50), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
appendLE(UInt16(entries.count), to: &archive)
|
|
||||||
appendLE(UInt16(entries.count), to: &archive)
|
|
||||||
appendLE(UInt32(centralDirectory.count), to: &archive)
|
|
||||||
appendLE(centralDirectoryOffset, to: &archive)
|
|
||||||
appendLE(UInt16(0), to: &archive)
|
|
||||||
try archive.write(to: archiveURL, options: .atomic)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ArchiveTestError: LocalizedError {
|
private enum ArchiveTestError: LocalizedError {
|
||||||
case failedToCreateArchive(String)
|
case failedToCreateArchive(String)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
World Manager can browse and install Minecraft Bedrock content on a trusted iPhone or iPad from macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
World Manager can browse Minecraft Bedrock content from a trusted iPhone or iPad on macOS using Apple's private `MobileDevice.framework` and the House Arrest service.
|
||||||
|
|
||||||
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
|
Connected-device sources are modeled as normal `MinecraftSource` values, but they are not scanned through a live filesystem mirror. The current implementation asks the device for library item summaries, metadata, icons, sizes, and directory listings through `AppleMobileDeviceSourceAccess`. Only explicit materialization operations, such as reveal/export/share, mirror an item subtree into a temporary local directory.
|
||||||
|
|
||||||
@ -87,18 +87,6 @@ During materialization:
|
|||||||
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
- `ContentPackageExporter` mirrors the selected item into archive staging when exporting connected-device content.
|
||||||
- Temporary materialized folders are treated as disposable.
|
- Temporary materialized folders are treated as disposable.
|
||||||
|
|
||||||
During installation:
|
|
||||||
|
|
||||||
- Package contents are fully extracted and validated on the Mac.
|
|
||||||
- The bridge creates an operation directory under `Documents/games/com.mojang/.world-manager-staging`.
|
|
||||||
- Files are uploaded through `AFCFileRefWrite`.
|
|
||||||
- The destination collection is created if needed.
|
|
||||||
- `AFCRenamePath` moves the completed staging directory into the appropriate collection.
|
|
||||||
- A unique destination directory is selected, so an existing world or folder is not overwritten.
|
|
||||||
- Failed staging trees are removed recursively through AFC.
|
|
||||||
|
|
||||||
The write symbols are loaded opportunistically. Their absence prevents installation but does not disable read-only device access.
|
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
Connected-device sources are persisted in the SQLite source cache with:
|
Connected-device sources are persisted in the SQLite source cache with:
|
||||||
@ -144,6 +132,3 @@ These commands require a trusted connected device and generally need to run outs
|
|||||||
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
- Behavior can change across macOS, iOS, iPadOS, and Minecraft releases.
|
||||||
- Device access requires trust, unlock state, and a vendable app container.
|
- Device access requires trust, unlock state, and a vendable app container.
|
||||||
- Connected-device export/reveal operations may be slower than local folder operations because they materialize remote content on demand.
|
- Connected-device export/reveal operations may be slower than local folder operations because they materialize remote content on demand.
|
||||||
- Device writes are serialized with scans and exports for the same device.
|
|
||||||
- Multi-item imports can be partially completed if the device disconnects between items. The app reports the completed count and rescans the destination when it reconnects.
|
|
||||||
- Pack replacement and removal are intentionally not implemented in the additive first version.
|
|
||||||
|
|||||||
248
docs/provider-architecture-design.md
Normal file
248
docs/provider-architecture-design.md
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# Provider Architecture Design
|
||||||
|
|
||||||
|
World Manager should treat Minecraft libraries as sources of content units that
|
||||||
|
can be discovered, inspected, cached, displayed, materialized, exported, and
|
||||||
|
eventually copied between sources. Bedrock local folders, Bedrock iOS devices,
|
||||||
|
Java local folders, and possible future platforms should be cohesive provider
|
||||||
|
modules behind one engine contract.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep provider-specific knowledge inside provider modules.
|
||||||
|
- Give the UI a uniform model for equivalent concepts: name, edition, kind,
|
||||||
|
source, icon, dates, size, availability, progress, and actions.
|
||||||
|
- Preserve variable metadata for platform details that do not translate across
|
||||||
|
editions.
|
||||||
|
- Allow scan workflows to stream events instead of forcing every provider into
|
||||||
|
the same fixed stages.
|
||||||
|
- Keep connected-device and remote-like sources free to throttle work more
|
||||||
|
aggressively than local filesystem sources.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
```text
|
||||||
|
Platforms/Bedrock
|
||||||
|
BedrockLocalFolderProvider
|
||||||
|
BedrockIOSDeviceProvider
|
||||||
|
BedrockContentScanner
|
||||||
|
BedrockMetadataReader
|
||||||
|
BedrockExporter
|
||||||
|
BedrockMaterializer
|
||||||
|
|
||||||
|
Platforms/Java
|
||||||
|
JavaLocalFolderProvider
|
||||||
|
JavaContentScanner
|
||||||
|
JavaMetadataReader
|
||||||
|
JavaExporter
|
||||||
|
JavaMaterializer
|
||||||
|
|
||||||
|
Engine
|
||||||
|
ProviderRegistry
|
||||||
|
SourceEngine
|
||||||
|
Scan orchestration
|
||||||
|
Cache/snapshot persistence
|
||||||
|
Generic indexing/search/projection
|
||||||
|
Generic action dispatch
|
||||||
|
|
||||||
|
UI
|
||||||
|
Generic source and item surfaces
|
||||||
|
Provider/edition-specific detail sections
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
represent an edition/access pairing such as Bedrock local folder, Bedrock iOS
|
||||||
|
device, or Java local folder.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol MinecraftPlatformProvider: Sendable {
|
||||||
|
var id: PlatformProviderID { get }
|
||||||
|
var displayName: String { get }
|
||||||
|
|
||||||
|
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
|
func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
|
||||||
|
func capabilities(for source: MinecraftSource) async -> SourceCapabilities
|
||||||
|
|
||||||
|
func scan(_ request: ProviderScanRequest) -> AsyncThrowingStream<ProviderEvent, Error>
|
||||||
|
func materialize(_ unit: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
||||||
|
func export(_ unit: MinecraftContentItem, in source: MinecraftSource, request: ExportRequest) async throws -> URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The current `SourceAccessMethod` is already close to this role. The refactor
|
||||||
|
should evolve it rather than replace the whole source system at once.
|
||||||
|
|
||||||
|
### Content Unit
|
||||||
|
|
||||||
|
The engine should pass around an edition-aware content unit with a strong common
|
||||||
|
surface and boxed platform metadata.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct MinecraftContentItem {
|
||||||
|
let id: URL
|
||||||
|
let folderURL: URL
|
||||||
|
let folderName: String
|
||||||
|
let sourceEdition: MinecraftEdition
|
||||||
|
let contentKind: MinecraftContentKind
|
||||||
|
let platformType: MinecraftPlatformContentType
|
||||||
|
let collectionRootURL: URL
|
||||||
|
|
||||||
|
var displayName: String
|
||||||
|
var iconURL: URL?
|
||||||
|
var modifiedDate: Date?
|
||||||
|
var sizeBytes: Int64?
|
||||||
|
var capabilities: ContentItemCapabilities
|
||||||
|
var platformMetadata: PlatformContentMetadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The model can retain compatibility shims during migration, but new code should
|
||||||
|
prefer edition, kind, capabilities, and boxed metadata over Bedrock-only fields.
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
|
||||||
|
Generic UI should avoid flattening provider metadata. Platform details should be
|
||||||
|
boxed and rendered by edition/provider-aware detail sections.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum PlatformContentMetadata: Hashable, Sendable, Codable {
|
||||||
|
case bedrock(BedrockContentMetadata)
|
||||||
|
case java(JavaContentMetadata)
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Status
|
||||||
|
|
||||||
|
Availability should be richer than local-vs-device.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum SourceAccessMode: String, Hashable, Sendable, Codable {
|
||||||
|
case localFileSystem
|
||||||
|
case securityScopedLocalFolder
|
||||||
|
case usbDevice
|
||||||
|
case networkDevice
|
||||||
|
case archive
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceAccessStatus: Hashable, Sendable, Codable {
|
||||||
|
var availability: SourceAvailability
|
||||||
|
var mode: SourceAccessMode
|
||||||
|
var displayName: String
|
||||||
|
var iconSystemName: String
|
||||||
|
var statusText: String?
|
||||||
|
var warningText: String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event-Driven Scanning
|
||||||
|
|
||||||
|
Providers should be able to stream discoveries, metadata, progress, and warnings.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum ProviderEvent: Sendable {
|
||||||
|
case accessStatusChanged(SourceAccessStatus)
|
||||||
|
case stageUpdated(WorkStage)
|
||||||
|
case discovered(MinecraftContentItem)
|
||||||
|
case inspected(MinecraftContentItem)
|
||||||
|
case warning(ProviderWarning)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine consumes events, updates source state, updates indexes, persists
|
||||||
|
snapshots, and exposes UI-ready projections.
|
||||||
|
|
||||||
|
### Source Candidate Discovery
|
||||||
|
|
||||||
|
Source candidate discovery is separate from source content discovery. Candidate
|
||||||
|
discovery answers whether a potential source exists in the current environment;
|
||||||
|
content discovery scans an accepted source for worlds, packs, mods, and other
|
||||||
|
items.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Engine asks providers for source candidates
|
||||||
|
-> each provider uses its own bounded discovery process
|
||||||
|
-> providers stream candidate events
|
||||||
|
-> engine deduplicates and filters already-added sources
|
||||||
|
-> UI shows suggestions that the user can accept
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, the Java local provider can check known macOS launcher roots and
|
||||||
|
shallow-search likely instance folders, while Bedrock local folders can remain a
|
||||||
|
no-op and rely on folder picking. Connected-device providers can later emit
|
||||||
|
device-backed candidates from USB or network discovery.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
### Engine Owns
|
||||||
|
|
||||||
|
- Provider registration/routing.
|
||||||
|
- Source candidate discovery orchestration and deduplication.
|
||||||
|
- Source lifecycle and persistence.
|
||||||
|
- Scan task ownership, cancellation, and worker limits.
|
||||||
|
- Cache and snapshot persistence hooks.
|
||||||
|
- Generic item search, sorting, counts, and projections.
|
||||||
|
- Generic action dispatch and user-facing error normalization.
|
||||||
|
|
||||||
|
### Provider Owns
|
||||||
|
|
||||||
|
- Access method details.
|
||||||
|
- Source candidate discovery strategy.
|
||||||
|
- Discovery layout and content markers.
|
||||||
|
- Metadata parsing.
|
||||||
|
- Platform relationships.
|
||||||
|
- Materialization.
|
||||||
|
- Export formats.
|
||||||
|
- Provider-specific progress stages and warnings.
|
||||||
|
- Provider-specific detail metadata.
|
||||||
|
|
||||||
|
## Current Fit
|
||||||
|
|
||||||
|
Existing app pieces already map to this design:
|
||||||
|
|
||||||
|
- `SourceAccessMethod`: provider-like boundary.
|
||||||
|
- `SourceAccessCoordinator`: provider registry/router.
|
||||||
|
- `SourceLibrary`: engine/orchestrator.
|
||||||
|
- `MinecraftSource`: source session and persisted model.
|
||||||
|
- `SourcePersistenceStore`: persistence.
|
||||||
|
- `SourceContentIndexer`: generic indexing plus Bedrock-specific relationship
|
||||||
|
logic that should be split.
|
||||||
|
- `ContentItemActionService` and `ContentPackageExporter`: action/export layer
|
||||||
|
that should become capability/provider aware.
|
||||||
|
|
||||||
|
The main mismatch is that shared models and UI currently carry Bedrock-specific
|
||||||
|
types and fields directly.
|
||||||
92
docs/provider-refactor-migration-plan.md
Normal file
92
docs/provider-refactor-migration-plan.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# Provider Refactor Migration Plan
|
||||||
|
|
||||||
|
This plan moves the app from a Bedrock-oriented source-access architecture to an
|
||||||
|
engine/platform-provider architecture while keeping behavior working at each
|
||||||
|
step.
|
||||||
|
|
||||||
|
## Phase 1: Document and Name the Boundary
|
||||||
|
|
||||||
|
- Add provider architecture documentation.
|
||||||
|
- Add neutral model names alongside existing names:
|
||||||
|
- `MinecraftEdition`
|
||||||
|
- `MinecraftContentKind`
|
||||||
|
- `MinecraftPlatformContentType`
|
||||||
|
- `PlatformContentMetadata`
|
||||||
|
- `ContentItemCapabilities`
|
||||||
|
- `SourceAccessStatus`
|
||||||
|
- `WorkStage`
|
||||||
|
- `ProviderEvent`
|
||||||
|
- Keep existing `MinecraftContentType` as a compatibility alias/wrapper until
|
||||||
|
call sites move.
|
||||||
|
|
||||||
|
## Phase 2: Neutralize Content Items
|
||||||
|
|
||||||
|
- Add edition, kind, platform type, capabilities, and platform metadata to
|
||||||
|
`MinecraftContentItem`.
|
||||||
|
- Preserve existing Bedrock fields as computed compatibility accessors where
|
||||||
|
practical.
|
||||||
|
- Move Bedrock world and pack metadata into `BedrockContentMetadata`.
|
||||||
|
- Update search text to pull from generic fields and provider metadata.
|
||||||
|
- Update tests/fixtures to construct items through the new neutral initializer.
|
||||||
|
|
||||||
|
## Phase 3: Provider-Shaped Access
|
||||||
|
|
||||||
|
- Introduce provider protocol types as a superset of current source access.
|
||||||
|
- Adapt `SourceAccessMethod` to emit provider IDs and access status.
|
||||||
|
- Register providers through a provider registry/coordinator.
|
||||||
|
- Rename current generic local folder access to Bedrock local folder access.
|
||||||
|
- Keep a compatibility typealias or wrapper named `LocalFolderSourceAccess` until
|
||||||
|
all call sites are migrated.
|
||||||
|
|
||||||
|
## Phase 4: Split Bedrock Platform Module
|
||||||
|
|
||||||
|
- Move Bedrock-specific scanner/metadata/export code under a Bedrock platform
|
||||||
|
namespace/folder.
|
||||||
|
- Rename `WorldScanner` to `BedrockContentScanner`.
|
||||||
|
- Rename `MinecraftContentMetadataReader` to `BedrockContentMetadataReader`.
|
||||||
|
- Keep temporary wrappers for Quick Look and legacy call sites.
|
||||||
|
- Move Bedrock relationship building out of generic indexing.
|
||||||
|
|
||||||
|
## Phase 5: Event-Driven Scan Pipeline
|
||||||
|
|
||||||
|
- Add provider events and work stages.
|
||||||
|
- Let providers report progress stages.
|
||||||
|
- Let the engine consume discovered/inspected events.
|
||||||
|
- Preserve existing scan-stage behavior until provider events fully replace it.
|
||||||
|
- Keep worker limits in the engine, with provider-declared concurrency policy as
|
||||||
|
a later extension.
|
||||||
|
|
||||||
|
## Phase 6: Capability-Aware Actions
|
||||||
|
|
||||||
|
- Move archive extension and portable export format selection off
|
||||||
|
`MinecraftContentType`.
|
||||||
|
- Add item/provider capabilities for reveal/export/share/copy.
|
||||||
|
- Adapt `ContentItemActionService` and exporters to route by provider/platform
|
||||||
|
type.
|
||||||
|
- Keep Bedrock package output unchanged.
|
||||||
|
|
||||||
|
## Phase 7: Java Local Provider
|
||||||
|
|
||||||
|
- Add Java local provider as read-only initially.
|
||||||
|
- Discover Java saves, resource packs, and datapacks.
|
||||||
|
- Add Java metadata reader for `level.dat` and `pack.mcmeta` incrementally.
|
||||||
|
- Export Java folder-backed content as `.zip` where supported.
|
||||||
|
- Do not add Java-Bedrock conversion in this phase.
|
||||||
|
|
||||||
|
## Phase 8: Cleanup
|
||||||
|
|
||||||
|
- Remove compatibility aliases after UI, tests, Quick Look, and exporters use
|
||||||
|
provider-aware models.
|
||||||
|
- Rename UI labels from Bedrock-specific categories where appropriate.
|
||||||
|
- Add provider fixtures and contract tests.
|
||||||
|
- Keep build/test verification passing after each phase.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
At each milestone:
|
||||||
|
|
||||||
|
- Run Swift tests.
|
||||||
|
- Run the app target build.
|
||||||
|
- Check existing Bedrock local and connected-device behavior remains intact.
|
||||||
|
- Add focused tests for model migration, indexing, export format selection, and
|
||||||
|
provider event consumption.
|
||||||
Loading…
Reference in New Issue
Block a user