Unknown changes
This commit is contained in:
parent
da53ee4e9b
commit
f64d3de90d
@ -265,15 +265,18 @@ nonisolated struct BedrockContentMetadata: Hashable, Sendable, Codable {
|
|||||||
nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable {
|
nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable {
|
||||||
var world: JavaWorldMetadata?
|
var world: JavaWorldMetadata?
|
||||||
var pack: JavaPackMetadata?
|
var pack: JavaPackMetadata?
|
||||||
|
var mod: JavaModMetadata?
|
||||||
var dataPacks: [JavaPackReference]
|
var dataPacks: [JavaPackReference]
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
world: JavaWorldMetadata? = nil,
|
world: JavaWorldMetadata? = nil,
|
||||||
pack: JavaPackMetadata? = nil,
|
pack: JavaPackMetadata? = nil,
|
||||||
|
mod: JavaModMetadata? = nil,
|
||||||
dataPacks: [JavaPackReference] = []
|
dataPacks: [JavaPackReference] = []
|
||||||
) {
|
) {
|
||||||
self.world = world
|
self.world = world
|
||||||
self.pack = pack
|
self.pack = pack
|
||||||
|
self.mod = mod
|
||||||
self.dataPacks = dataPacks
|
self.dataPacks = dataPacks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -288,7 +291,46 @@ nonisolated struct JavaWorldMetadata: Hashable, Sendable, Codable {
|
|||||||
|
|
||||||
nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable {
|
nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable {
|
||||||
var packFormat: Int?
|
var packFormat: Int?
|
||||||
|
var supportedFormats: String?
|
||||||
var description: 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 {
|
nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable {
|
||||||
@ -434,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 }
|
||||||
|
|||||||
@ -788,8 +788,11 @@ enum JavaContentScanner {
|
|||||||
let metadata = JavaContentMetadataReader.metadata(for: item)
|
let metadata = JavaContentMetadataReader.metadata(for: item)
|
||||||
enrichedItem.displayName = metadata?.displayName ?? displayName(for: item)
|
enrichedItem.displayName = metadata?.displayName ?? displayName(for: item)
|
||||||
enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata)
|
enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata)
|
||||||
if let packMetadata = metadata?.pack {
|
if metadata?.pack != nil || metadata?.mod != nil {
|
||||||
enrichedItem.platformMetadata = .java(JavaContentMetadata(pack: packMetadata))
|
enrichedItem.platformMetadata = .java(JavaContentMetadata(
|
||||||
|
pack: metadata?.pack,
|
||||||
|
mod: metadata?.mod
|
||||||
|
))
|
||||||
}
|
}
|
||||||
enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil
|
enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil
|
||||||
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Foundation
|
|||||||
nonisolated struct JavaArchiveMetadata: Hashable, Sendable {
|
nonisolated struct JavaArchiveMetadata: Hashable, Sendable {
|
||||||
var displayName: String?
|
var displayName: String?
|
||||||
var pack: JavaPackMetadata?
|
var pack: JavaPackMetadata?
|
||||||
|
var mod: JavaModMetadata?
|
||||||
var iconEntryPath: String?
|
var iconEntryPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +55,7 @@ enum JavaContentMetadataReader {
|
|||||||
return JavaArchiveMetadata(
|
return JavaArchiveMetadata(
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
pack: pack,
|
pack: pack,
|
||||||
|
mod: nil,
|
||||||
iconEntryPath: iconURL?.lastPathComponent
|
iconEntryPath: iconURL?.lastPathComponent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -74,6 +76,7 @@ enum JavaContentMetadataReader {
|
|||||||
return JavaArchiveMetadata(
|
return JavaArchiveMetadata(
|
||||||
displayName: modMetadata?.displayName,
|
displayName: modMetadata?.displayName,
|
||||||
pack: pack,
|
pack: pack,
|
||||||
|
mod: modMetadata?.metadata,
|
||||||
iconEntryPath: iconEntryPath
|
iconEntryPath: iconEntryPath
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -128,11 +131,14 @@ enum JavaContentMetadataReader {
|
|||||||
|
|
||||||
return JavaPackMetadata(
|
return JavaPackMetadata(
|
||||||
packFormat: packObject["pack_format"] as? Int,
|
packFormat: packObject["pack_format"] as? Int,
|
||||||
|
supportedFormats: supportedFormatsValue(from: packObject["supported_formats"]),
|
||||||
description: textValue(from: packObject["description"])
|
description: textValue(from: packObject["description"])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func modMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? {
|
nonisolated private static func modMetadata(
|
||||||
|
from archive: ZipArchiveReader
|
||||||
|
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||||
if let tomlMetadata = modTOMLMetadata(from: archive) {
|
if let tomlMetadata = modTOMLMetadata(from: archive) {
|
||||||
return tomlMetadata
|
return tomlMetadata
|
||||||
}
|
}
|
||||||
@ -148,7 +154,9 @@ enum JavaContentMetadataReader {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func modTOMLMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? {
|
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"]
|
let entryNames = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"]
|
||||||
for entryName in entryNames {
|
for entryName in entryNames {
|
||||||
guard
|
guard
|
||||||
@ -160,10 +168,20 @@ enum JavaContentMetadataReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let firstModSection = firstTOMLSection(named: "[[mods]]", in: text)
|
let firstModSection = firstTOMLSection(named: "[[mods]]", in: text)
|
||||||
|
let dependenciesSection = firstTOMLSection(named: "[[dependencies.", in: text)
|
||||||
let displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
|
let displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
|
||||||
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
|
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
|
||||||
if displayName != nil || logoFile != nil {
|
let metadata = JavaModMetadata(
|
||||||
return (displayName, logoFile)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +191,7 @@ enum JavaContentMetadataReader {
|
|||||||
nonisolated private static func modJSONMetadata(
|
nonisolated private static func modJSONMetadata(
|
||||||
from archive: ZipArchiveReader,
|
from archive: ZipArchiveReader,
|
||||||
entryName: String
|
entryName: String
|
||||||
) -> (displayName: String?, iconPath: String?)? {
|
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||||
guard
|
guard
|
||||||
let entry = archive.entry(named: entryName),
|
let entry = archive.entry(named: entryName),
|
||||||
let data = try? archive.extract(entry),
|
let data = try? archive.extract(entry),
|
||||||
@ -191,9 +209,20 @@ enum JavaContentMetadataReader {
|
|||||||
iconPath = nil
|
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 (
|
return (
|
||||||
(jsonObject["name"] as? String)?.nilIfBlank,
|
(jsonObject["name"] as? String)?.nilIfBlank,
|
||||||
iconPath?.nilIfBlank
|
iconPath?.nilIfBlank,
|
||||||
|
metadata
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,6 +260,75 @@ enum JavaContentMetadataReader {
|
|||||||
return nil
|
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(
|
nonisolated private static func iconEntryPath(
|
||||||
in archive: ZipArchiveReader,
|
in archive: ZipArchiveReader,
|
||||||
preferredPath: String?,
|
preferredPath: String?,
|
||||||
@ -275,6 +373,45 @@ enum JavaContentMetadataReader {
|
|||||||
|
|
||||||
return nil
|
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 {
|
private extension String {
|
||||||
|
|||||||
@ -59,21 +59,27 @@ 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
|
||||||
|
|
||||||
|
if restoresPersistedSources {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startsBackgroundRefresh {
|
||||||
localSourceRefreshTask = Task { [weak self] in
|
localSourceRefreshTask = Task { [weak self] in
|
||||||
await self?.runLocalSourceRefreshLoop()
|
await self?.runLocalSourceRefreshLoop()
|
||||||
}
|
}
|
||||||
@ -84,6 +90,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
connectedDeviceRefreshTask?.cancel()
|
connectedDeviceRefreshTask?.cancel()
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -73,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
|
||||||
)
|
)
|
||||||
@ -82,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 {
|
||||||
@ -100,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 {
|
||||||
@ -130,8 +137,8 @@ private struct ItemListHeaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showsSubtitle || showsProjectionLoadingState {
|
if showsSubtitle {
|
||||||
Text(displaySubtitle)
|
Text(subtitle)
|
||||||
.appTextStyle(.supporting)
|
.appTextStyle(.supporting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,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,
|
||||||
@ -226,7 +226,7 @@ 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
|
||||||
@ -253,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)
|
||||||
|
|||||||
@ -32,14 +32,19 @@ 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 {
|
||||||
@ -919,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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,9 @@ struct SourcesSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
.transaction { transaction in
|
||||||
|
transaction.animation = nil
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
Button(action: discoverSourcesAction) {
|
Button(action: discoverSourcesAction) {
|
||||||
@ -158,10 +161,9 @@ struct SourcesSidebarView: View {
|
|||||||
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
|
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
|
||||||
switch node.row {
|
switch node.row {
|
||||||
case .source(let source):
|
case .source(let source):
|
||||||
SourceHeaderRow(source: source)
|
SourceHeaderRow(source: source, isSelected: selection == node.selection)
|
||||||
.tag(node.selection as SidebarSelection?)
|
.tag(node.selection as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
rescanSourceAction(source)
|
rescanSourceAction(source)
|
||||||
@ -185,7 +187,6 @@ struct SourcesSidebarView: View {
|
|||||||
)
|
)
|
||||||
.tag(node.selection as SidebarSelection?)
|
.tag(node.selection as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
|
||||||
case .sourceCandidate(let candidate):
|
case .sourceCandidate(let candidate):
|
||||||
SourceCandidateRow(
|
SourceCandidateRow(
|
||||||
candidate: candidate,
|
candidate: candidate,
|
||||||
@ -195,7 +196,6 @@ struct SourcesSidebarView: View {
|
|||||||
)
|
)
|
||||||
.tag(node.selection as SidebarSelection?)
|
.tag(node.selection as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,10 +206,7 @@ private struct SourceCandidateRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: symbolName)
|
Label {
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(width: 16)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(candidate.displayName)
|
Text(candidate.displayName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -219,6 +216,10 @@ private struct SourceCandidateRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
@ -250,12 +251,13 @@ private struct SidebarFilterRow: View {
|
|||||||
let filter: SidebarFilter
|
let filter: SidebarFilter
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack {
|
||||||
Image(systemName: filter.iconName)
|
Label {
|
||||||
.frame(width: 16)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text(filter.title)
|
Text(filter.title)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: filter.iconName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@ -275,24 +277,31 @@ private struct SidebarSourcesSectionHeaderView: View {
|
|||||||
|
|
||||||
private struct SourceHeaderRow: View {
|
private struct SourceHeaderRow: View {
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack {
|
||||||
Image(systemName: headerSymbolName)
|
Label {
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text(source.displayName)
|
Text(source.displayName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
} icon: {
|
||||||
|
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 {
|
||||||
@ -301,8 +310,6 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connection: DeviceConnection? {
|
private var connection: DeviceConnection? {
|
||||||
@ -353,7 +360,7 @@ private struct SourceHeaderRow: View {
|
|||||||
private var statusAccessory: some View {
|
private var statusAccessory: some View {
|
||||||
if source.isScanning {
|
if source.isScanning {
|
||||||
if let scanProgress = source.scanProgress {
|
if let scanProgress = source.scanProgress {
|
||||||
CircularScanProgressView(progress: scanProgress)
|
CircularScanProgressView(progress: scanProgress, isSelected: isSelected)
|
||||||
} else {
|
} else {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.appActivityIndicatorStyle(.small)
|
.appActivityIndicatorStyle(.small)
|
||||||
@ -364,10 +371,11 @@ private struct SourceHeaderRow: View {
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@ -394,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"))
|
||||||
@ -429,13 +442,8 @@ 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,
|
|
||||||
connection: entry.device.connection,
|
|
||||||
tint: iconColor
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(entry.device.name)
|
Text(entry.device.name)
|
||||||
.appTextStyle(.rowTitle)
|
.appTextStyle(.rowTitle)
|
||||||
@ -444,6 +452,13 @@ private struct ConnectedDeviceRow: View {
|
|||||||
Text(statusText)
|
Text(statusText)
|
||||||
.appTextStyle(.supportingCompact)
|
.appTextStyle(.supportingCompact)
|
||||||
}
|
}
|
||||||
|
} icon: {
|
||||||
|
ConnectedDeviceTransportIcon(
|
||||||
|
baseSymbolName: iconName,
|
||||||
|
connection: entry.device.connection,
|
||||||
|
tint: iconColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
|||||||
@ -250,9 +250,15 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
|
|
||||||
[[mods]]
|
[[mods]]
|
||||||
modId = "examplemod"
|
modId = "examplemod"
|
||||||
|
version = "1.2.3"
|
||||||
displayName = "Example Java Mod"
|
displayName = "Example Java Mod"
|
||||||
logoFile = "icon.png"
|
logoFile = "icon.png"
|
||||||
|
authors = "Alex, Sam"
|
||||||
description = "A test mod."
|
description = "A test mod."
|
||||||
|
|
||||||
|
[[dependencies.examplemod]]
|
||||||
|
modId = "minecraft"
|
||||||
|
versionRange = "[1.21,)"
|
||||||
""".write(
|
""".write(
|
||||||
to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"),
|
to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"),
|
||||||
atomically: true,
|
atomically: true,
|
||||||
@ -274,7 +280,11 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
{
|
{
|
||||||
"pack": {
|
"pack": {
|
||||||
"description": "Example Resource Pack",
|
"description": "Example Resource Pack",
|
||||||
"pack_format": 34
|
"pack_format": 34,
|
||||||
|
"supported_formats": {
|
||||||
|
"min_inclusive": 34,
|
||||||
|
"max_inclusive": 42
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".write(to: resourceSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8)
|
""".write(to: resourceSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8)
|
||||||
@ -313,6 +323,11 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
if case .java(let metadata) = enrichedMod.platformMetadata {
|
if case .java(let metadata) = enrichedMod.platformMetadata {
|
||||||
#expect(metadata.pack?.description == "Example Mod Resources")
|
#expect(metadata.pack?.description == "Example Mod Resources")
|
||||||
#expect(metadata.pack?.packFormat == 31)
|
#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 {
|
} else {
|
||||||
Issue.record("Expected Java metadata")
|
Issue.record("Expected Java metadata")
|
||||||
}
|
}
|
||||||
@ -321,6 +336,7 @@ struct World_Manager_for_MinecraftTests {
|
|||||||
if case .java(let metadata) = enrichedResource.platformMetadata {
|
if case .java(let metadata) = enrichedResource.platformMetadata {
|
||||||
#expect(metadata.pack?.description == "Example Resource Pack")
|
#expect(metadata.pack?.description == "Example Resource Pack")
|
||||||
#expect(metadata.pack?.packFormat == 34)
|
#expect(metadata.pack?.packFormat == 34)
|
||||||
|
#expect(metadata.pack?.supportedFormats == "34-42")
|
||||||
} else {
|
} else {
|
||||||
Issue.record("Expected Java metadata")
|
Issue.record("Expected Java metadata")
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user