Compare commits
6 Commits
2639bca571
...
ffb6e497ec
| Author | SHA1 | Date | |
|---|---|---|---|
| ffb6e497ec | |||
| ca21654b44 | |||
| 6e728724bb | |||
| ba6edf6cc4 | |||
| bd177832c0 | |||
| bb4ef36f44 |
@ -115,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 []
|
||||||
|
|||||||
@ -64,6 +64,23 @@ nonisolated struct SourceProbeResult: Hashable, Sendable {
|
|||||||
let warnings: [String]
|
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,
|
||||||
|
sourceRootURL.standardizedFileURL.absoluteString
|
||||||
|
].joined(separator: "::")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
|
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
|
||||||
case pending
|
case pending
|
||||||
case running
|
case running
|
||||||
@ -101,6 +118,12 @@ nonisolated enum ProviderEvent: Sendable {
|
|||||||
case warning(ProviderWarning)
|
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
|
||||||
|
|||||||
@ -672,6 +672,60 @@ enum JavaContentScanner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
nonisolated static func discoverItems(
|
||||||
in searchRootURL: URL,
|
in searchRootURL: URL,
|
||||||
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
@ -679,47 +733,49 @@ enum JavaContentScanner {
|
|||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var discoveredItems: [MinecraftContentItem] = []
|
var discoveredItems: [MinecraftContentItem] = []
|
||||||
|
|
||||||
let savesRootURL = existingDirectory(
|
for scanRootURL in contentScanRoots(for: searchRootURL, fileManager: fileManager) {
|
||||||
named: "saves",
|
let savesRootURL = existingDirectory(
|
||||||
in: searchRootURL,
|
named: "saves",
|
||||||
fileManager: fileManager
|
in: scanRootURL,
|
||||||
) ?? searchRootURL
|
|
||||||
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
|
|
||||||
discoveredItems.append(contentsOf: worldItems)
|
|
||||||
|
|
||||||
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: searchRootURL, fileManager: fileManager) {
|
|
||||||
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
|
|
||||||
discoveredItems.append(contentsOf: resourcePackItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let dataPacksURL = existingDirectory(named: "datapacks", in: searchRootURL, fileManager: fileManager) {
|
|
||||||
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
|
||||||
in: dataPacksURL,
|
|
||||||
contentKind: .dataPack,
|
|
||||||
platformType: .dataPack,
|
|
||||||
packageExtension: "zip",
|
|
||||||
fileManager: fileManager
|
fileManager: fileManager
|
||||||
))
|
) ?? scanRootURL
|
||||||
}
|
let worldItems = try discoverWorlds(in: savesRootURL, fileManager: fileManager)
|
||||||
|
discoveredItems.append(contentsOf: worldItems)
|
||||||
|
|
||||||
if let shaderPacksURL = existingDirectory(named: "shaderpacks", in: searchRootURL, fileManager: fileManager) {
|
if let resourcePacksURL = existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager) {
|
||||||
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
let resourcePackItems = try discoverResourcePacks(in: resourcePacksURL, fileManager: fileManager)
|
||||||
in: shaderPacksURL,
|
discoveredItems.append(contentsOf: resourcePackItems)
|
||||||
contentKind: .shaderPack,
|
}
|
||||||
platformType: .shaderPack,
|
|
||||||
packageExtension: "zip",
|
|
||||||
fileManager: fileManager
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let modsURL = existingDirectory(named: "mods", in: searchRootURL, fileManager: fileManager) {
|
if let dataPacksURL = existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager) {
|
||||||
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
discoveredItems.append(contentsOf: try discoverJavaPackages(
|
||||||
in: modsURL,
|
in: dataPacksURL,
|
||||||
contentKind: .mod,
|
contentKind: .dataPack,
|
||||||
platformType: .mod,
|
platformType: .dataPack,
|
||||||
packageExtension: "jar",
|
packageExtension: "zip",
|
||||||
fileManager: fileManager
|
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.sort(by: WorldScanner.sortItems)
|
||||||
@ -752,21 +808,32 @@ enum JavaContentScanner {
|
|||||||
|
|
||||||
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
|
nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let candidateRoots = [
|
var snapshots: [CollectionSnapshot] = []
|
||||||
existingDirectory(named: "saves", in: sourceRootURL, fileManager: fileManager),
|
for scanRootURL in contentScanRoots(for: sourceRootURL, fileManager: fileManager) {
|
||||||
existingDirectory(named: "resourcepacks", in: sourceRootURL, fileManager: fileManager),
|
let candidateRoots = [
|
||||||
existingDirectory(named: "datapacks", in: sourceRootURL, fileManager: fileManager),
|
existingDirectory(named: "saves", in: scanRootURL, fileManager: fileManager),
|
||||||
existingDirectory(named: "shaderpacks", in: sourceRootURL, fileManager: fileManager),
|
existingDirectory(named: "resourcepacks", in: scanRootURL, fileManager: fileManager),
|
||||||
existingDirectory(named: "mods", in: sourceRootURL, fileManager: fileManager)
|
existingDirectory(named: "datapacks", in: scanRootURL, fileManager: fileManager),
|
||||||
]
|
existingDirectory(named: "shaderpacks", in: scanRootURL, fileManager: fileManager),
|
||||||
|
existingDirectory(named: "mods", in: scanRootURL, fileManager: fileManager)
|
||||||
|
]
|
||||||
|
|
||||||
return candidateRoots.compactMap { collectionURL in
|
for collectionURL in candidateRoots {
|
||||||
guard let collectionURL else {
|
guard let collectionURL else {
|
||||||
return nil
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let snapshot = collectionSnapshot(
|
||||||
|
for: collectionURL,
|
||||||
|
sourceRootURL: sourceRootURL,
|
||||||
|
fileManager: fileManager
|
||||||
|
) {
|
||||||
|
snapshots.append(snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionSnapshot(for: collectionURL, fileManager: fileManager)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
nonisolated private static func discoverWorlds(in savesRootURL: URL, fileManager: FileManager) throws -> [MinecraftContentItem] {
|
||||||
@ -885,7 +952,7 @@ enum JavaContentScanner {
|
|||||||
let children = (try? fileManager.contentsOfDirectory(
|
let children = (try? fileManager.contentsOfDirectory(
|
||||||
at: url,
|
at: url,
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
options: [.skipsHiddenFiles]
|
options: []
|
||||||
)) ?? []
|
)) ?? []
|
||||||
candidates.append(contentsOf: children.filter {
|
candidates.append(contentsOf: children.filter {
|
||||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||||
@ -893,6 +960,40 @@ enum JavaContentScanner {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func collapsedCandidates(
|
||||||
|
_ candidates: [SourceCandidate],
|
||||||
|
under root: URL,
|
||||||
|
providerID: PlatformProviderID
|
||||||
|
) -> [SourceCandidate] {
|
||||||
|
let uniqueCandidates = Dictionary(grouping: candidates, by: \.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>) {
|
nonisolated private static func javaProbeScore(for url: URL, fileManager: FileManager) -> (value: Int, kinds: Set<MinecraftContentKind>) {
|
||||||
var score = 0
|
var score = 0
|
||||||
var kinds = Set<MinecraftContentKind>()
|
var kinds = Set<MinecraftContentKind>()
|
||||||
@ -931,8 +1032,108 @@ enum JavaContentScanner {
|
|||||||
return (score, kinds)
|
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(normalizedURL.path).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(standardizedURL.path).inserted else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(standardizedURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func collectionSnapshot(
|
nonisolated private static func collectionSnapshot(
|
||||||
for collectionURL: URL,
|
for collectionURL: URL,
|
||||||
|
sourceRootURL: URL,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) -> CollectionSnapshot? {
|
) -> CollectionSnapshot? {
|
||||||
guard fileManager.fileExists(atPath: collectionURL.path) else {
|
guard fileManager.fileExists(atPath: collectionURL.path) else {
|
||||||
@ -969,12 +1170,15 @@ enum JavaContentScanner {
|
|||||||
].joined(separator: "@")
|
].joined(separator: "@")
|
||||||
}.joined(separator: "|")
|
}.joined(separator: "|")
|
||||||
|
|
||||||
|
let folderName = relativePath(from: sourceRootURL.standardizedFileURL, to: collectionURL.standardizedFileURL)
|
||||||
|
?? collectionURL.lastPathComponent
|
||||||
|
|
||||||
return CollectionSnapshot(
|
return CollectionSnapshot(
|
||||||
folderName: collectionURL.lastPathComponent,
|
folderName: folderName,
|
||||||
modifiedDate: modifiedDate,
|
modifiedDate: modifiedDate,
|
||||||
childDirectoryCount: childSnapshots.count,
|
childDirectoryCount: childSnapshots.count,
|
||||||
fingerprint: [
|
fingerprint: [
|
||||||
collectionURL.lastPathComponent,
|
folderName,
|
||||||
String(childSnapshots.count),
|
String(childSnapshots.count),
|
||||||
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
modifiedDate?.timeIntervalSince1970.formatted() ?? "nil",
|
||||||
childFingerprint
|
childFingerprint
|
||||||
@ -982,6 +1186,16 @@ enum JavaContentScanner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
nonisolated private static func displayName(for item: MinecraftContentItem) -> String {
|
||||||
guard item.contentKind == .world else {
|
guard item.contentKind == .world else {
|
||||||
return item.folderName
|
return item.folderName
|
||||||
|
|||||||
@ -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,10 +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
|
||||||
|
|
||||||
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
|
||||||
@ -80,6 +88,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() }
|
||||||
}
|
}
|
||||||
@ -92,6 +101,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]
|
||||||
}
|
}
|
||||||
@ -110,6 +135,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()
|
||||||
@ -137,6 +164,17 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func perform(_ command: SourceLibraryCommand) {
|
||||||
|
switch command {
|
||||||
|
case .discoverSourceCandidates:
|
||||||
|
discoverSourceCandidates()
|
||||||
|
case .refreshAllSources:
|
||||||
|
for source in visibleSources where source.availability == .available {
|
||||||
|
startScan(for: source.id, mode: .fullScan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func addSource(at url: URL) async -> URL {
|
func addSource(at url: URL) async -> URL {
|
||||||
let selectedURL = url.standardizedFileURL
|
let selectedURL = url.standardizedFileURL
|
||||||
let probe = await sourceAccessMethod.probeLocalFolder(selectedURL)
|
let probe = await sourceAccessMethod.probeLocalFolder(selectedURL)
|
||||||
@ -146,6 +184,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
let edition = probe?.edition ?? .bedrock
|
let edition = probe?.edition ?? .bedrock
|
||||||
|
|
||||||
if sources.contains(where: { $0.id == normalizedURL }) {
|
if sources.contains(where: { $0.id == normalizedURL }) {
|
||||||
|
sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL }
|
||||||
updateSource(normalizedURL) { source in
|
updateSource(normalizedURL) { source in
|
||||||
if source.bookmarkData == nil {
|
if source.bookmarkData == nil {
|
||||||
source.bookmarkData = bookmarkData
|
source.bookmarkData = bookmarkData
|
||||||
@ -184,7 +223,51 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
if let warning = probe?.warnings.first {
|
if let warning = probe?.warnings.first {
|
||||||
source.scanDiagnostic = warning
|
source.scanDiagnostic = warning
|
||||||
}
|
}
|
||||||
return addSource(source, shouldPersist: true, shouldScan: true)
|
let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
|
sourceCandidates.removeAll { $0.sourceRootURL == sourceID }
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSource(candidate: SourceCandidate) async -> URL {
|
||||||
|
let normalizedURL = candidate.sourceRootURL.standardizedFileURL
|
||||||
|
let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
|
||||||
|
|
||||||
|
if sources.contains(where: { $0.id == normalizedURL }) {
|
||||||
|
updateSource(normalizedURL) { 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
|
||||||
|
}
|
||||||
|
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == normalizedURL }
|
||||||
|
startScan(for: normalizedURL, mode: .fullScan)
|
||||||
|
return normalizedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID }
|
||||||
|
return sourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@ -238,6 +321,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)
|
||||||
}
|
}
|
||||||
@ -558,6 +677,36 @@ 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
|
||||||
|
source.id == candidate.sourceRootURL.standardizedFileURL
|
||||||
|
|| source.folderURL == candidate.sourceRootURL.standardizedFileURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@ -11,6 +11,7 @@ enum SourceDiscoveryMode: Sendable {
|
|||||||
protocol SourceAccessMethod: Sendable {
|
protocol SourceAccessMethod: Sendable {
|
||||||
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
||||||
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult?
|
nonisolated func 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 accessStatus(for source: MinecraftSource) async -> SourceAccessStatus
|
||||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||||
@ -44,6 +45,12 @@ extension SourceAccessMethod {
|
|||||||
return nil
|
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,
|
||||||
@ -251,6 +258,40 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
|||||||
return bestProbe
|
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,
|
||||||
|
|||||||
@ -215,13 +215,61 @@ struct BedrockLocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
|
|
||||||
struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
struct JavaLocalFolderSourceAccess: SourceAccessMethod {
|
||||||
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder"
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "java-local-folder"
|
||||||
|
private let candidateDiscoveryRoots: [URL]?
|
||||||
|
|
||||||
nonisolated init() {}
|
nonisolated init(candidateDiscoveryRoots: [URL]? = nil) {
|
||||||
|
self.candidateDiscoveryRoots = candidateDiscoveryRoots
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
nonisolated func probeLocalFolder(_ url: URL) async -> SourceProbeResult? {
|
||||||
JavaContentScanner.probeLocalFolder(url, providerID: accessorIdentifier)
|
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 {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
_ = source
|
_ = source
|
||||||
return SourceAccessDescriptor(
|
return SourceAccessDescriptor(
|
||||||
|
|||||||
@ -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,6 +7,8 @@ import SwiftUI
|
|||||||
struct ItemDetailColumnView: View {
|
struct ItemDetailColumnView: View {
|
||||||
let item: MinecraftContentItem?
|
let item: MinecraftContentItem?
|
||||||
let source: MinecraftSource?
|
let source: MinecraftSource?
|
||||||
|
let sourceCandidate: SourceCandidate?
|
||||||
|
let connectedDevice: ConnectedDeviceSidebarEntry?
|
||||||
let showsSourceDetails: Bool
|
let showsSourceDetails: Bool
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
@ -22,11 +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 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,
|
||||||
@ -46,6 +50,24 @@ struct ItemDetailColumnView: View {
|
|||||||
)
|
)
|
||||||
} else if showsSourceDetails, let source {
|
} else if showsSourceDetails, let source {
|
||||||
SourceDetailView(source: source)
|
SourceDetailView(source: source)
|
||||||
|
} else if let sourceCandidate {
|
||||||
|
SourceCandidateDetailView(
|
||||||
|
candidate: sourceCandidate,
|
||||||
|
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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -262,8 +262,12 @@ 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 },
|
||||||
@ -351,6 +355,8 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: PreviewFixtures.featuredWorld,
|
item: PreviewFixtures.featuredWorld,
|
||||||
source: PreviewFixtures.primarySource,
|
source: PreviewFixtures.primarySource,
|
||||||
|
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 +371,10 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||||
exportAction: {},
|
exportAction: {},
|
||||||
revealAction: {},
|
revealAction: {},
|
||||||
shareAction: { _ in }
|
shareAction: { _ in },
|
||||||
|
addCandidateSourceAction: { _ in },
|
||||||
|
revealCandidateAction: { _ in },
|
||||||
|
addConnectedDeviceAction: { _ in }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,8 +43,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@ -74,9 +76,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
|
||||||
@ -116,6 +124,8 @@ struct ContentView: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: resolvedCurrentSelectedItem,
|
item: resolvedCurrentSelectedItem,
|
||||||
source: resolvedCurrentSource,
|
source: resolvedCurrentSource,
|
||||||
|
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) } ?? [],
|
||||||
@ -148,7 +158,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shareItem(item, from: anchorView)
|
shareItem(item, from: anchorView)
|
||||||
}
|
},
|
||||||
|
addCandidateSourceAction: addCandidateSource(_:),
|
||||||
|
revealCandidateAction: revealCandidateInFinder(_:),
|
||||||
|
addConnectedDeviceAction: addConnectedDeviceSource(from:)
|
||||||
)
|
)
|
||||||
.frame(minWidth: 450)
|
.frame(minWidth: 450)
|
||||||
}
|
}
|
||||||
@ -183,7 +196,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) {
|
||||||
@ -249,6 +262,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
|
||||||
@ -581,6 +610,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addCandidateSource(_ candidate: SourceCandidate) {
|
||||||
|
Task {
|
||||||
|
let sourceID = await library.addSource(candidate: candidate)
|
||||||
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
|
selectedItemID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealCandidateInFinder(_ candidate: SourceCandidate) {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
|
||||||
|
}
|
||||||
|
|
||||||
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||||
let fileURLType = UTType.fileURL.identifier
|
let fileURLType = UTType.fileURL.identifier
|
||||||
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
||||||
@ -640,8 +681,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,10 @@ 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):
|
||||||
@ -88,6 +92,8 @@ enum ItemCollectionProjector {
|
|||||||
|
|
||||||
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):
|
||||||
@ -103,6 +109,10 @@ 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):
|
||||||
@ -122,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):
|
||||||
|
|||||||
@ -5,14 +5,18 @@ import SwiftUI
|
|||||||
|
|
||||||
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)
|
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, _), .contentKind(let sourceID, _):
|
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
|
||||||
return sourceID
|
return sourceID
|
||||||
|
case .sourceCandidate, .connectedDevice:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -28,8 +32,12 @@ struct SidebarFilter: Identifiable, Hashable {
|
|||||||
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
|
||||||
@ -57,9 +65,43 @@ struct SourcesSidebarView: View {
|
|||||||
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !sourceCandidates.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(sourceCandidates) { candidate in
|
||||||
|
SourceCandidateRow(
|
||||||
|
candidate: candidate,
|
||||||
|
onSelect: {
|
||||||
|
selection = .sourceCandidate(candidateID: candidate.id)
|
||||||
|
},
|
||||||
|
addAction: {
|
||||||
|
addCandidateSourceAction(candidate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView(title: "Found Sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.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")
|
||||||
@ -111,15 +153,68 @@ struct SourcesSidebarView: View {
|
|||||||
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
||||||
ConnectedDeviceRow(
|
ConnectedDeviceRow(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
|
onSelect: {
|
||||||
|
selection = .connectedDevice(deviceID: entry.id)
|
||||||
|
},
|
||||||
addAction: entry.hasMinecraftContainer ? {
|
addAction: entry.hasMinecraftContainer ? {
|
||||||
addConnectedDeviceAction(entry)
|
addConnectedDeviceAction(entry)
|
||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
|
.tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SourceCandidateRow: View {
|
||||||
|
let candidate: SourceCandidate
|
||||||
|
let onSelect: () -> Void
|
||||||
|
let addAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 16)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(candidate.displayName)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Button(action: addAction) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
.appMiniProminentButton()
|
||||||
|
.help("Add Source")
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onSelect)
|
||||||
|
.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
|
let isIndented: Bool
|
||||||
@ -305,6 +400,7 @@ private struct CircularScanProgressView: View {
|
|||||||
|
|
||||||
private struct ConnectedDeviceRow: View {
|
private struct ConnectedDeviceRow: View {
|
||||||
let entry: ConnectedDeviceSidebarEntry
|
let entry: ConnectedDeviceSidebarEntry
|
||||||
|
let onSelect: () -> Void
|
||||||
let addAction: (() -> Void)?
|
let addAction: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -334,6 +430,8 @@ private struct ConnectedDeviceRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.opacity(addAction == nil ? 0.68 : 1)
|
.opacity(addAction == nil ? 0.68 : 1)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onSelect)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconName: String {
|
private var iconName: String {
|
||||||
|
|||||||
@ -326,6 +326,135 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 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 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 {
|
@Test func sourceLibraryAddSourceResolvesJavaWrapperFolder() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
|||||||
@ -186,11 +186,32 @@ enum ProviderEvent: Sendable {
|
|||||||
The engine consumes events, updates source state, updates indexes, persists
|
The engine consumes events, updates source state, updates indexes, persists
|
||||||
snapshots, and exposes UI-ready projections.
|
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
|
## Responsibilities
|
||||||
|
|
||||||
### Engine Owns
|
### Engine Owns
|
||||||
|
|
||||||
- Provider registration/routing.
|
- Provider registration/routing.
|
||||||
|
- Source candidate discovery orchestration and deduplication.
|
||||||
- Source lifecycle and persistence.
|
- Source lifecycle and persistence.
|
||||||
- Scan task ownership, cancellation, and worker limits.
|
- Scan task ownership, cancellation, and worker limits.
|
||||||
- Cache and snapshot persistence hooks.
|
- Cache and snapshot persistence hooks.
|
||||||
@ -200,6 +221,7 @@ snapshots, and exposes UI-ready projections.
|
|||||||
### Provider Owns
|
### Provider Owns
|
||||||
|
|
||||||
- Access method details.
|
- Access method details.
|
||||||
|
- Source candidate discovery strategy.
|
||||||
- Discovery layout and content markers.
|
- Discovery layout and content markers.
|
||||||
- Metadata parsing.
|
- Metadata parsing.
|
||||||
- Platform relationships.
|
- Platform relationships.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user