Unknown changes

This commit is contained in:
John Burwell 2026-08-02 19:58:37 -05:00
parent da53ee4e9b
commit f64d3de90d
11 changed files with 497 additions and 101 deletions

View File

@ -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 }

View File

@ -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)

View File

@ -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 {

View File

@ -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()

View File

@ -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 {

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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
} }
} }

View File

@ -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)

View File

@ -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")
} }