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 {
|
||||
var world: JavaWorldMetadata?
|
||||
var pack: JavaPackMetadata?
|
||||
var mod: JavaModMetadata?
|
||||
var dataPacks: [JavaPackReference]
|
||||
|
||||
nonisolated init(
|
||||
world: JavaWorldMetadata? = nil,
|
||||
pack: JavaPackMetadata? = nil,
|
||||
mod: JavaModMetadata? = nil,
|
||||
dataPacks: [JavaPackReference] = []
|
||||
) {
|
||||
self.world = world
|
||||
self.pack = pack
|
||||
self.mod = mod
|
||||
self.dataPacks = dataPacks
|
||||
}
|
||||
}
|
||||
@ -288,7 +291,46 @@ nonisolated struct JavaWorldMetadata: Hashable, Sendable, Codable {
|
||||
|
||||
nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable {
|
||||
var packFormat: Int?
|
||||
var supportedFormats: String?
|
||||
var description: String?
|
||||
|
||||
nonisolated init(
|
||||
packFormat: Int? = nil,
|
||||
supportedFormats: String? = nil,
|
||||
description: String? = nil
|
||||
) {
|
||||
self.packFormat = packFormat
|
||||
self.supportedFormats = supportedFormats
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct JavaModMetadata: Hashable, Sendable, Codable {
|
||||
var modID: String?
|
||||
var version: String?
|
||||
var description: String?
|
||||
var authors: [String]
|
||||
var license: String?
|
||||
var environment: String?
|
||||
var minecraftVersionRequirement: String?
|
||||
|
||||
nonisolated init(
|
||||
modID: String? = nil,
|
||||
version: String? = nil,
|
||||
description: String? = nil,
|
||||
authors: [String] = [],
|
||||
license: String? = nil,
|
||||
environment: String? = nil,
|
||||
minecraftVersionRequirement: String? = nil
|
||||
) {
|
||||
self.modID = modID
|
||||
self.version = version
|
||||
self.description = description
|
||||
self.authors = authors
|
||||
self.license = license
|
||||
self.environment = environment
|
||||
self.minecraftVersionRequirement = minecraftVersionRequirement
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable {
|
||||
@ -434,6 +476,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
|
||||
values.append(packMetadataDetails?.minimumEngineVersion ?? "")
|
||||
values.append(packReferences.map(\.name).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
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
@ -788,8 +788,11 @@ enum JavaContentScanner {
|
||||
let metadata = JavaContentMetadataReader.metadata(for: item)
|
||||
enrichedItem.displayName = metadata?.displayName ?? displayName(for: item)
|
||||
enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata)
|
||||
if let packMetadata = metadata?.pack {
|
||||
enrichedItem.platformMetadata = .java(JavaContentMetadata(pack: packMetadata))
|
||||
if metadata?.pack != nil || metadata?.mod != nil {
|
||||
enrichedItem.platformMetadata = .java(JavaContentMetadata(
|
||||
pack: metadata?.pack,
|
||||
mod: metadata?.mod
|
||||
))
|
||||
}
|
||||
enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil
|
||||
enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL)
|
||||
|
||||
@ -6,6 +6,7 @@ import Foundation
|
||||
nonisolated struct JavaArchiveMetadata: Hashable, Sendable {
|
||||
var displayName: String?
|
||||
var pack: JavaPackMetadata?
|
||||
var mod: JavaModMetadata?
|
||||
var iconEntryPath: String?
|
||||
}
|
||||
|
||||
@ -54,6 +55,7 @@ enum JavaContentMetadataReader {
|
||||
return JavaArchiveMetadata(
|
||||
displayName: nil,
|
||||
pack: pack,
|
||||
mod: nil,
|
||||
iconEntryPath: iconURL?.lastPathComponent
|
||||
)
|
||||
}
|
||||
@ -74,6 +76,7 @@ enum JavaContentMetadataReader {
|
||||
return JavaArchiveMetadata(
|
||||
displayName: modMetadata?.displayName,
|
||||
pack: pack,
|
||||
mod: modMetadata?.metadata,
|
||||
iconEntryPath: iconEntryPath
|
||||
)
|
||||
}
|
||||
@ -128,11 +131,14 @@ enum JavaContentMetadataReader {
|
||||
|
||||
return JavaPackMetadata(
|
||||
packFormat: packObject["pack_format"] as? Int,
|
||||
supportedFormats: supportedFormatsValue(from: packObject["supported_formats"]),
|
||||
description: textValue(from: packObject["description"])
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func modMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? {
|
||||
nonisolated private static func modMetadata(
|
||||
from archive: ZipArchiveReader
|
||||
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||
if let tomlMetadata = modTOMLMetadata(from: archive) {
|
||||
return tomlMetadata
|
||||
}
|
||||
@ -148,7 +154,9 @@ enum JavaContentMetadataReader {
|
||||
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"]
|
||||
for entryName in entryNames {
|
||||
guard
|
||||
@ -160,10 +168,20 @@ enum JavaContentMetadataReader {
|
||||
}
|
||||
|
||||
let firstModSection = firstTOMLSection(named: "[[mods]]", in: text)
|
||||
let dependenciesSection = firstTOMLSection(named: "[[dependencies.", in: text)
|
||||
let displayName = tomlStringValue(forKey: "displayName", in: firstModSection)
|
||||
let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection)
|
||||
if displayName != nil || logoFile != nil {
|
||||
return (displayName, logoFile)
|
||||
let metadata = JavaModMetadata(
|
||||
modID: tomlStringValue(forKey: "modId", in: firstModSection),
|
||||
version: tomlStringValue(forKey: "version", in: firstModSection),
|
||||
description: tomlStringValue(forKey: "description", in: firstModSection),
|
||||
authors: stringListValue(from: tomlStringValue(forKey: "authors", in: firstModSection)),
|
||||
license: tomlStringValue(forKey: "license", in: text),
|
||||
environment: nil,
|
||||
minecraftVersionRequirement: minecraftDependencyRequirement(fromTOMLSection: dependenciesSection)
|
||||
)
|
||||
if displayName != nil || logoFile != nil || metadata.hasValues {
|
||||
return (displayName, logoFile, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,7 +191,7 @@ enum JavaContentMetadataReader {
|
||||
nonisolated private static func modJSONMetadata(
|
||||
from archive: ZipArchiveReader,
|
||||
entryName: String
|
||||
) -> (displayName: String?, iconPath: String?)? {
|
||||
) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? {
|
||||
guard
|
||||
let entry = archive.entry(named: entryName),
|
||||
let data = try? archive.extract(entry),
|
||||
@ -191,9 +209,20 @@ enum JavaContentMetadataReader {
|
||||
iconPath = nil
|
||||
}
|
||||
|
||||
let metadata = JavaModMetadata(
|
||||
modID: (jsonObject["id"] as? String)?.nilIfBlank,
|
||||
version: (jsonObject["version"] as? String)?.nilIfBlank,
|
||||
description: textValue(from: jsonObject["description"]),
|
||||
authors: authorsValue(from: jsonObject["authors"]),
|
||||
license: licenseValue(from: jsonObject["license"]),
|
||||
environment: (jsonObject["environment"] as? String)?.nilIfBlank,
|
||||
minecraftVersionRequirement: minecraftDependencyRequirement(fromJSON: jsonObject)
|
||||
)
|
||||
|
||||
return (
|
||||
(jsonObject["name"] as? String)?.nilIfBlank,
|
||||
iconPath?.nilIfBlank
|
||||
iconPath?.nilIfBlank,
|
||||
metadata
|
||||
)
|
||||
}
|
||||
|
||||
@ -231,6 +260,75 @@ enum JavaContentMetadataReader {
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func minecraftDependencyRequirement(fromTOMLSection text: String) -> String? {
|
||||
guard tomlStringValue(forKey: "modId", in: text) == "minecraft" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tomlStringValue(forKey: "versionRange", in: text)
|
||||
}
|
||||
|
||||
nonisolated private static func minecraftDependencyRequirement(fromJSON jsonObject: [String: Any]) -> String? {
|
||||
for key in ["depends", "dependencies", "breaks"] {
|
||||
guard let dependencies = jsonObject[key] as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let minecraft = dependencies["minecraft"] as? String {
|
||||
return minecraft.nilIfBlank
|
||||
}
|
||||
if let minecraft = dependencies["minecraft"] as? [String: Any] {
|
||||
return textValue(from: minecraft["version"])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func authorsValue(from value: Any?) -> [String] {
|
||||
if let author = value as? String {
|
||||
return stringListValue(from: author)
|
||||
}
|
||||
|
||||
if let authors = value as? [String] {
|
||||
return authors.compactMap(\.nilIfBlank)
|
||||
}
|
||||
|
||||
if let authors = value as? [[String: Any]] {
|
||||
return authors.compactMap { author in
|
||||
textValue(from: author["name"])
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated private static func licenseValue(from value: Any?) -> String? {
|
||||
if let license = value as? String {
|
||||
return license.nilIfBlank
|
||||
}
|
||||
|
||||
if let licenses = value as? [String] {
|
||||
let values = licenses.compactMap(\.nilIfBlank)
|
||||
return values.isEmpty ? nil : values.joined(separator: ", ")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func stringListValue(from value: String?) -> [String] {
|
||||
guard let value else {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split { character in
|
||||
character == "," || character == ";"
|
||||
}
|
||||
.map(String.init)
|
||||
.compactMap(\.nilIfBlank)
|
||||
}
|
||||
|
||||
nonisolated private static func iconEntryPath(
|
||||
in archive: ZipArchiveReader,
|
||||
preferredPath: String?,
|
||||
@ -275,6 +373,45 @@ enum JavaContentMetadataReader {
|
||||
|
||||
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 {
|
||||
|
||||
@ -59,21 +59,27 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
|
||||
notificationService: ScanNotificationServicing? = nil,
|
||||
itemActionService: ContentItemActionService = ContentItemActionService()
|
||||
itemActionService: ContentItemActionService = ContentItemActionService(),
|
||||
restoresPersistedSources: Bool = true,
|
||||
startsBackgroundRefresh: Bool = true
|
||||
) {
|
||||
self.persistenceStore = persistenceStore
|
||||
self.sourceAccessMethod = sourceAccessMethod
|
||||
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
||||
self.notificationService = notificationService ?? ScanNotificationService.shared
|
||||
self.itemActionService = itemActionService
|
||||
self.isRestoringPersistedSources = restoresPersistedSources
|
||||
|
||||
if restoresPersistedSources {
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
||||
}
|
||||
}
|
||||
|
||||
if startsBackgroundRefresh {
|
||||
localSourceRefreshTask = Task { [weak self] in
|
||||
await self?.runLocalSourceRefreshLoop()
|
||||
}
|
||||
@ -84,6 +90,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
connectedDeviceRefreshTask?.cancel()
|
||||
|
||||
@ -162,7 +162,7 @@ struct ItemDetailView: View {
|
||||
recordSection(title: "Technical Details") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
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)
|
||||
if let spawn = item.worldMetadata?.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: "Version", value: item.packVersion ?? "Unavailable")
|
||||
if let minimumEngineVersion = item.packMetadataDetails?.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] {
|
||||
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 {
|
||||
let packCount = behaviorPacks.count + resourcePacks.count
|
||||
@ -388,6 +430,22 @@ struct ItemDetailView: View {
|
||||
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 {
|
||||
if exportTitle != nil {
|
||||
switch item.contentType {
|
||||
|
||||
@ -73,8 +73,8 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
sourceName: sourceName,
|
||||
showsSourceName: showsSourceName,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
showsSubtitle: showsSubtitle,
|
||||
subtitle: navigationSubtitleText,
|
||||
showsSubtitle: showsSubtitle || showsProjectionLoadingState,
|
||||
isRefreshing: isRefreshing,
|
||||
showsProjectionLoadingState: showsProjectionLoadingState
|
||||
)
|
||||
@ -82,7 +82,7 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
}
|
||||
.searchable(text: $searchText, prompt: searchPrompt)
|
||||
.navigationTitle(isEmpty ? "Library" : title)
|
||||
.navigationSubtitle(isEmpty ? "" : subtitle)
|
||||
.navigationSubtitle(isEmpty ? "" : navigationSubtitleText)
|
||||
.toolbar {
|
||||
if !isEmpty {
|
||||
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 {
|
||||
@ -130,8 +137,8 @@ private struct ItemListHeaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if showsSubtitle || showsProjectionLoadingState {
|
||||
Text(displaySubtitle)
|
||||
if showsSubtitle {
|
||||
Text(subtitle)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
}
|
||||
@ -141,14 +148,6 @@ private struct ItemListHeaderView: View {
|
||||
.padding(.bottom, 12)
|
||||
.appListHeaderSurface()
|
||||
}
|
||||
|
||||
private var displaySubtitle: String {
|
||||
if showsProjectionLoadingState {
|
||||
return "Loading items..."
|
||||
}
|
||||
|
||||
return subtitle
|
||||
}
|
||||
}
|
||||
|
||||
private struct ItemListLoadingOverlay: View {
|
||||
|
||||
@ -6,7 +6,7 @@ import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
|
||||
enum PreviewFixtures {
|
||||
nonisolated enum PreviewFixtures {
|
||||
static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000)
|
||||
|
||||
static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1")
|
||||
@ -132,7 +132,7 @@ enum PreviewFixtures {
|
||||
)
|
||||
|
||||
static let primarySource: MinecraftSource = {
|
||||
var source = MinecraftSource(folderURL: sourceOneURL)
|
||||
var source = MinecraftSource(folderURL: sourceOneURL, availability: .available)
|
||||
source.displayName = "Kid iPad Imports"
|
||||
source.displayItems = [
|
||||
featuredWorld,
|
||||
@ -226,7 +226,7 @@ enum PreviewFixtures {
|
||||
}()
|
||||
|
||||
static let secondarySource: MinecraftSource = {
|
||||
var source = MinecraftSource(folderURL: sourceTwoURL)
|
||||
var source = MinecraftSource(folderURL: sourceTwoURL, availability: .available)
|
||||
source.displayName = "Downloads"
|
||||
source.displayItems = [secondLibraryPack]
|
||||
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
|
||||
struct SidebarColumnPreviewContainer: View {
|
||||
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
|
||||
|
||||
@ -32,14 +32,19 @@ struct ContentView: View {
|
||||
private let directoryPreviewLimit = 12
|
||||
private let projectionLoadingDelay: Duration = .milliseconds(150)
|
||||
|
||||
init() {
|
||||
let dependencies = ContentViewDependencies.makeDefault()
|
||||
init(
|
||||
dependencies: ContentViewDependencies = ContentViewDependencies.makeDefault(),
|
||||
initialSidebarSelection: SidebarSelection? = nil,
|
||||
initialItemID: MinecraftContentItem.ID? = nil
|
||||
) {
|
||||
self.connectedDeviceAccess = dependencies.connectedDeviceAccess
|
||||
self.deviceSourceFactory = dependencies.deviceSourceFactory
|
||||
self.itemActionService = dependencies.itemActionService
|
||||
_library = StateObject(
|
||||
wrappedValue: dependencies.library
|
||||
)
|
||||
_selectedSidebarSelection = State(initialValue: initialSidebarSelection)
|
||||
_selectedItemID = State(initialValue: initialItemID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -919,8 +924,19 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
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 {
|
||||
case sidebarSubtle
|
||||
case sidebarAccent
|
||||
case sidebarSelected
|
||||
case heroMetadata
|
||||
}
|
||||
|
||||
@ -181,6 +182,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
||||
return AnyShapeStyle(.secondary)
|
||||
case .sidebarAccent:
|
||||
return AnyShapeStyle(Color.appAccent)
|
||||
case .sidebarSelected:
|
||||
return AnyShapeStyle(.white.opacity(0.92))
|
||||
case .heroMetadata:
|
||||
return AnyShapeStyle(.white.opacity(0.95))
|
||||
}
|
||||
@ -192,6 +195,8 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
||||
return AnyShapeStyle(.secondary.opacity(0.12))
|
||||
case .sidebarAccent:
|
||||
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
||||
case .sidebarSelected:
|
||||
return AnyShapeStyle(.white.opacity(0.16))
|
||||
case .heroMetadata:
|
||||
return AnyShapeStyle(.white.opacity(0.14))
|
||||
}
|
||||
@ -201,7 +206,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
||||
switch style {
|
||||
case .heroMetadata:
|
||||
return 10
|
||||
case .sidebarSubtle, .sidebarAccent:
|
||||
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
|
||||
return 7
|
||||
}
|
||||
}
|
||||
@ -210,7 +215,7 @@ private struct AppCapsuleLabelModifier: ViewModifier {
|
||||
switch style {
|
||||
case .heroMetadata:
|
||||
return 7
|
||||
case .sidebarSubtle, .sidebarAccent:
|
||||
case .sidebarSubtle, .sidebarAccent, .sidebarSelected:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,6 +86,9 @@ struct SourcesSidebarView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.transaction { transaction in
|
||||
transaction.animation = nil
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: discoverSourcesAction) {
|
||||
@ -158,10 +161,9 @@ struct SourcesSidebarView: View {
|
||||
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
|
||||
switch node.row {
|
||||
case .source(let source):
|
||||
SourceHeaderRow(source: source)
|
||||
SourceHeaderRow(source: source, isSelected: selection == node.selection)
|
||||
.tag(node.selection as SidebarSelection?)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.contextMenu {
|
||||
Button("Rescan \"\(source.displayName)\"") {
|
||||
rescanSourceAction(source)
|
||||
@ -185,7 +187,6 @@ struct SourcesSidebarView: View {
|
||||
)
|
||||
.tag(node.selection as SidebarSelection?)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
||||
case .sourceCandidate(let candidate):
|
||||
SourceCandidateRow(
|
||||
candidate: candidate,
|
||||
@ -195,7 +196,6 @@ struct SourcesSidebarView: View {
|
||||
)
|
||||
.tag(node.selection as SidebarSelection?)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -206,10 +206,7 @@ private struct SourceCandidateRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(candidate.displayName)
|
||||
.lineLimit(1)
|
||||
@ -219,6 +216,10 @@ private struct SourceCandidateRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
@ -250,12 +251,13 @@ private struct SidebarFilterRow: View {
|
||||
let filter: SidebarFilter
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: filter.iconName)
|
||||
.frame(width: 16)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Label {
|
||||
Text(filter.title)
|
||||
} icon: {
|
||||
Image(systemName: filter.iconName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -275,24 +277,31 @@ private struct SidebarSourcesSectionHeaderView: View {
|
||||
|
||||
private struct SourceHeaderRow: View {
|
||||
let source: MinecraftSource
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: headerSymbolName)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Label {
|
||||
Text(source.displayName)
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: headerSymbolName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let availabilityBadgeText {
|
||||
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
|
||||
SourceAvailabilityBadge(
|
||||
text: availabilityBadgeText,
|
||||
emphasis: availabilityBadgeEmphasis,
|
||||
isSelected: isSelected
|
||||
)
|
||||
}
|
||||
|
||||
if let connection {
|
||||
SourceConnectionBadge(connection: connection)
|
||||
SourceConnectionBadge(connection: connection, isSelected: isSelected)
|
||||
}
|
||||
|
||||
if showsStatusAccessory {
|
||||
@ -301,8 +310,6 @@ private struct SourceHeaderRow: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
private var connection: DeviceConnection? {
|
||||
@ -353,7 +360,7 @@ private struct SourceHeaderRow: View {
|
||||
private var statusAccessory: some View {
|
||||
if source.isScanning {
|
||||
if let scanProgress = source.scanProgress {
|
||||
CircularScanProgressView(progress: scanProgress)
|
||||
CircularScanProgressView(progress: scanProgress, isSelected: isSelected)
|
||||
} else {
|
||||
ProgressView()
|
||||
.appActivityIndicatorStyle(.small)
|
||||
@ -364,10 +371,11 @@ private struct SourceHeaderRow: View {
|
||||
|
||||
private struct SourceConnectionBadge: View {
|
||||
let connection: DeviceConnection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbolName)
|
||||
.appCapsuleLabelStyle(.sidebarSubtle)
|
||||
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : .sidebarSubtle)
|
||||
.help(helpText)
|
||||
.accessibilityLabel(helpText)
|
||||
}
|
||||
@ -394,30 +402,35 @@ private struct SourceConnectionBadge: View {
|
||||
private struct SourceAvailabilityBadge: View {
|
||||
let text: String
|
||||
let emphasis: Bool
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
|
||||
.appCapsuleLabelStyle(isSelected ? .sidebarSelected : emphasis ? .sidebarAccent : .sidebarSubtle)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularScanProgressView: View {
|
||||
let progress: Double
|
||||
let isSelected: Bool
|
||||
|
||||
private let size: CGFloat = 17
|
||||
private let lineWidth: CGFloat = 1.4
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.secondary.opacity(0.18), lineWidth: 3)
|
||||
.stroke(isSelected ? .white.opacity(0.18) : Color.secondary.opacity(0.24), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.02, min(progress, 1)))
|
||||
.trim(from: 0, to: max(0, min(progress, 1)))
|
||||
.stroke(
|
||||
Color.appAccent,
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||
isSelected ? .white.opacity(0.86) : Color.appAccent,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Scan progress")
|
||||
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
|
||||
@ -429,13 +442,8 @@ private struct ConnectedDeviceRow: View {
|
||||
let addAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ConnectedDeviceTransportIcon(
|
||||
baseSymbolName: iconName,
|
||||
connection: entry.device.connection,
|
||||
tint: iconColor
|
||||
)
|
||||
|
||||
HStack(alignment: .top) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.device.name)
|
||||
.appTextStyle(.rowTitle)
|
||||
@ -444,6 +452,13 @@ private struct ConnectedDeviceRow: View {
|
||||
Text(statusText)
|
||||
.appTextStyle(.supportingCompact)
|
||||
}
|
||||
} icon: {
|
||||
ConnectedDeviceTransportIcon(
|
||||
baseSymbolName: iconName,
|
||||
connection: entry.device.connection,
|
||||
tint: iconColor
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
|
||||
@ -250,9 +250,15 @@ struct World_Manager_for_MinecraftTests {
|
||||
|
||||
[[mods]]
|
||||
modId = "examplemod"
|
||||
version = "1.2.3"
|
||||
displayName = "Example Java Mod"
|
||||
logoFile = "icon.png"
|
||||
authors = "Alex, Sam"
|
||||
description = "A test mod."
|
||||
|
||||
[[dependencies.examplemod]]
|
||||
modId = "minecraft"
|
||||
versionRange = "[1.21,)"
|
||||
""".write(
|
||||
to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"),
|
||||
atomically: true,
|
||||
@ -274,7 +280,11 @@ struct World_Manager_for_MinecraftTests {
|
||||
{
|
||||
"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)
|
||||
@ -313,6 +323,11 @@ struct World_Manager_for_MinecraftTests {
|
||||
if case .java(let metadata) = enrichedMod.platformMetadata {
|
||||
#expect(metadata.pack?.description == "Example Mod Resources")
|
||||
#expect(metadata.pack?.packFormat == 31)
|
||||
#expect(metadata.mod?.modID == "examplemod")
|
||||
#expect(metadata.mod?.version == "1.2.3")
|
||||
#expect(metadata.mod?.description == "A test mod.")
|
||||
#expect(metadata.mod?.authors == ["Alex", "Sam"])
|
||||
#expect(metadata.mod?.minecraftVersionRequirement == "[1.21,)")
|
||||
} else {
|
||||
Issue.record("Expected Java metadata")
|
||||
}
|
||||
@ -321,6 +336,7 @@ struct World_Manager_for_MinecraftTests {
|
||||
if case .java(let metadata) = enrichedResource.platformMetadata {
|
||||
#expect(metadata.pack?.description == "Example Resource Pack")
|
||||
#expect(metadata.pack?.packFormat == 34)
|
||||
#expect(metadata.pack?.supportedFormats == "34-42")
|
||||
} else {
|
||||
Issue.record("Expected Java metadata")
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user