From 42366c171339c55659501340af462766f18e3ec0 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Thu, 28 May 2026 14:00:34 -0500 Subject: [PATCH] devce status and scan progress, library restoration --- .../ItemDetailColumnViews.swift | 367 +++++++++ .../Models/MinecraftSource.swift | 158 ++++ .../Services/SourceLibrary.swift | 712 +++++++++++++++--- .../SidebarColumnViews.swift | 167 ++-- .../AppleMobileDeviceAccess.swift | 127 ++++ .../AppleMobileDeviceBridge.h | 17 + .../AppleMobileDeviceBridge.m | 208 ++++- .../AppleMobileDeviceSourceAccess.swift | 114 ++- .../Core/SourceAccessCoordinator.swift | 32 + 9 files changed, 1668 insertions(+), 234 deletions(-) diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index 3a23141..d64f383 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -87,6 +87,21 @@ struct ItemDetailColumnView: View { } private struct SourceDetailView: View { + private enum StageStatus { + case pending + case inProgress + case completed + } + + private struct StageRow: Identifiable { + let id: String + let title: String + let detail: String + let status: StageStatus + let progress: Double? + let showsIndeterminateProgress: Bool + } + let source: MinecraftSource var body: some View { @@ -101,6 +116,10 @@ private struct SourceDetailView: View { .foregroundStyle(.secondary) } + if showsStatusSection { + sourceStatusSection + } + sourceSection(title: "Overview", rows: overviewRows) sourceSection(title: "Contents", rows: contentRows) sourceSection(title: "Location", rows: locationRows) @@ -123,6 +142,136 @@ private struct SourceDetailView: View { } } + private var showsStatusSection: Bool { + if source.isScanning || source.scanError != nil || source.availability != .available { + return true + } + + guard !source.scanStatus.isEmpty else { + return false + } + + if source.scanStatus == "No Minecraft items found." { + return false + } + + if source.scanStatus.hasPrefix("Loaded ") { + return false + } + + return true + } + + private var statusTitle: String { + if !source.isScanning, source.availability != .available { + return source.availabilityDisplayText + } + + if let scanError = source.scanError, !scanError.isEmpty { + return "Scan Failed" + } + + if source.isScanning { + return source.liveScanStatusTitle + } + + if !source.scanStatus.isEmpty { + return source.scanStatus + } + + return "Scanning Minecraft library..." + } + + private var statusDetail: String? { + if !source.isScanning, source.availability != .available { + if let scanDiagnostic = source.scanDiagnostic, !scanDiagnostic.isEmpty { + return scanDiagnostic + } + + if let cachedAvailabilityDetailText = source.cachedAvailabilityDetailText { + return cachedAvailabilityDetailText + } + + if let lastScanDate = source.lastScanDate { + return "Cached from \(lastScanDate.formatted(date: .abbreviated, time: .shortened))" + } + + return nil + } + + if let scanError = source.scanError, !scanError.isEmpty { + return scanError + } + + if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty { + return diagnostic + } + + if source.isScanning { + return nil + } + + return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" + } + + @ViewBuilder + private var sourceStatusSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Status") + .font(.headline) + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 10) { + if source.showsIndeterminateScanActivityIndicator { + ProgressView() + .controlSize(.small) + } else if !source.isScanning { + sourceStatusIcon + } + + Text(statusTitle) + .font(.subheadline.weight(.semibold)) + } + + if let statusDetail, !statusDetail.isEmpty { + Text(statusDetail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if source.isScanning { + Divider() + .padding(.vertical, 2) + + VStack(alignment: .leading, spacing: 10) { + ForEach(stageRows) { stage in + sourceStageRow(stage) + } + } + } + } + .padding(18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + + @ViewBuilder + private var sourceStatusIcon: some View { + if source.availability == .limited { + Image(systemName: "lock.circle.fill") + .foregroundStyle(Color.appAccent) + } else if source.availability != .available { + Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle") + .foregroundStyle(.secondary) + } else if source.scanError != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } else { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + } + private var overviewRows: [(String, String)] { var rows: [(String, String)] = [ ("Type", sourceTypeLabel), @@ -215,6 +364,224 @@ private struct SourceDetailView: View { } } + private var stageRows: [StageRow] { + [ + previewStageRow, + sizeStageRow + ] + } + + private var previewStageRow: StageRow { + let total = source.indexedItemCount + let progress = total > 0 ? min(Double(source.previewLoadedCount) / Double(total), 1) : 0 + let hasPreviewWorkStarted = source.previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") + let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total + let shouldShowIndeterminatePreviewProgress = hasPreviewWorkStarted + && source.isScanning + && (source.scanProgress ?? 0) < 0.65 + + let status: StageStatus + if previewsAreFullyLoaded || source.scanPhase == .sizing || source.scanPhase == .completed { + status = .completed + } else if hasPreviewWorkStarted { + status = .inProgress + } else { + status = .pending + } + + let detail: String + switch status { + case .completed: + detail = finishedStageDetail(duration: source.previewStageDuration ?? source.previewStageElapsed) + case .inProgress: + detail = stageProgressDetail( + completed: source.previewLoadedCount, + total: total, + unit: "previews loaded", + elapsed: source.previewStageElapsed + ) + case .pending: + detail = "Waiting for discovery to finish" + } + + return StageRow( + id: "previews", + title: "Previews", + detail: detail, + status: status, + progress: status == .completed ? 1 : progress, + showsIndeterminateProgress: shouldShowIndeterminatePreviewProgress + ) + } + + private var sizeStageRow: StageRow { + let total = source.indexedItemCount + let progress = total > 0 ? min(Double(source.sizeLoadedCount) / Double(total), 1) : 0 + let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total + + let status: StageStatus + switch source.scanPhase { + case .sizing: + status = .inProgress + case .completed: + status = .completed + case .discovering, .metadata, .previews, .idle: + status = .pending + } + + let detail: String + switch status { + case .completed: + detail = finishedStageDetail(duration: source.sizeStageDuration) + case .inProgress: + detail = stageProgressDetail( + completed: source.sizeLoadedCount, + total: total, + unit: "sizes calculated", + elapsed: source.sizeStageElapsed + ) + case .pending: + detail = previewsAreFullyLoaded + ? "Preparing size calculations..." + : "Waiting for previews to finish loading" + } + + return StageRow( + id: "sizes", + title: "Sizes", + detail: detail, + status: status, + progress: status == .completed ? 1 : progress, + showsIndeterminateProgress: false + ) + } + + @ViewBuilder + private func sourceStageRow(_ stage: StageRow) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: stageIconName(for: stage.status)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(stageIconColor(for: stage.status)) + .frame(width: 14) + + Text(stage.title) + .font(.subheadline.weight(.semibold)) + + Spacer() + + Text(stage.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + + if stage.status == .completed { + EmptyView() + } else if stage.showsIndeterminateProgress { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.small) + .tint(Color.appAccent) + } else if let progress = stage.progress { + ProgressView(value: progress, total: 1) + .tint(stage.status == .pending ? .secondary.opacity(0.35) : Color.appAccent) + .opacity(stage.status == .pending ? 0.55 : 1) + } + } + } + + private func stageIconName(for status: StageStatus) -> String { + switch status { + case .pending: + return "circle" + case .inProgress: + return "clock" + case .completed: + return "checkmark.circle.fill" + } + } + + private func stageIconColor(for status: StageStatus) -> Color { + switch status { + case .pending: + return .secondary.opacity(0.7) + case .inProgress: + return Color.appAccent + case .completed: + return .green + } + } + + private func stageProgressDetail( + completed: Int, + total: Int, + unit: String, + elapsed: TimeInterval? + ) -> String { + guard total > 0 else { + return "Starting..." + } + + var detail = "\(completed) of \(total) \(unit)" + + if let remainingEstimate = estimateRemainingTime( + completed: completed, + total: total, + elapsed: elapsed + ) { + detail += " • about \(friendlyDuration(remainingEstimate)) left" + } + + return detail + } + + private func finishedStageDetail(duration: TimeInterval?) -> String { + guard let duration else { + return "Finished" + } + + return "Finished in \(friendlyDuration(duration))" + } + + private func estimateRemainingTime( + completed: Int, + total: Int, + elapsed: TimeInterval? + ) -> TimeInterval? { + guard + let elapsed, + elapsed >= 10, + completed >= 10, + total > 0, + completed < total + else { + return nil + } + + let completionFraction = Double(completed) / Double(total) + guard completionFraction >= 0.05 else { + return nil + } + + let secondsPerItem = elapsed / Double(completed) + let remaining = max(Double(total - completed) * secondsPerItem, 1) + guard remaining >= 60 else { + return nil + } + + return remaining + } + + private func friendlyDuration(_ duration: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.maximumUnitCount = duration >= 3600 ? 2 : 1 + formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : duration >= 60 ? [.minute] : [.second] + formatter.includesApproximationPhrase = false + formatter.includesTimeRemainingPhrase = false + return formatter.string(from: duration) ?? "a moment" + } + private func itemCount(for type: MinecraftContentType) -> Int { source.items.filter { $0.contentType == type }.count } diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 9f5223d..c2c4950 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -7,6 +7,15 @@ import Foundation +enum SourceScanPhase { + case discovering + case metadata + case previews + case sizing + case completed + case idle +} + struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL @@ -29,6 +38,12 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var scanProgress: Double? var indexedItemCount: Int var indexedDetailCount: Int + var previewLoadedCount: Int + var sizeLoadedCount: Int + var previewStageElapsed: TimeInterval? + var previewStageDuration: TimeInterval? + var sizeStageElapsed: TimeInterval? + var sizeStageDuration: TimeInterval? var lastScanDate: Date? nonisolated init( @@ -67,6 +82,12 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { self.scanProgress = nil self.indexedItemCount = 0 self.indexedDetailCount = 0 + self.previewLoadedCount = 0 + self.sizeLoadedCount = 0 + self.previewStageElapsed = nil + self.previewStageDuration = nil + self.sizeStageElapsed = nil + self.sizeStageDuration = nil self.lastScanDate = nil } @@ -74,10 +95,147 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { displayItems.count } + var hasCachedContent: Bool { + !displayItems.isEmpty || !rawItems.isEmpty || snapshot != nil + } + + var isOfflineCached: Bool { + availability != .available && hasCachedContent + } + + var availabilityDisplayText: String { + switch availability { + case .available: + return "Available" + case .unknown: + return "Checking availability" + case .disconnected: + return origin.kind == .connectedDevice ? "Device offline" : "Folder offline" + case .limited: + return origin.kind == .connectedDevice ? "Device access limited" : "Limited access" + case .unavailable: + return origin.kind == .connectedDevice ? "Device unavailable" : "Folder unavailable" + } + } + + var cachedAvailabilityDetailText: String? { + guard isOfflineCached else { + return nil + } + + switch availability { + case .disconnected: + return origin.kind == .connectedDevice + ? "Showing cached results until this device reconnects." + : "Showing cached results until this folder becomes reachable again." + case .limited: + return origin.kind == .connectedDevice + ? "Showing cached results until the device is unlocked and trusted." + : "Showing cached results until full access is restored." + case .unavailable, .unknown: + return "Showing cached results while the source is unavailable." + case .available: + return nil + } + } + var items: [MinecraftContentItem] { displayItems } + var scanPhase: SourceScanPhase { + guard isScanning else { + if scanStatus.hasPrefix("Loaded ") || scanStatus == "No Minecraft items found." { + return .completed + } + return .idle + } + + if sizeLoadedCount > 0 { + return .sizing + } + + if previewLoadedCount > 0 { + return .previews + } + + if let scanProgress { + if scanProgress >= 0.75 { + return .sizing + } + if scanProgress >= 0.65 { + return .previews + } + if scanProgress >= 0.1 { + return .metadata + } + } + + if scanStatus.contains("Calculating sizes") { + return .sizing + } + if scanStatus.contains("Loading previews") { + return .previews + } + if scanStatus.contains("metadata") { + return .metadata + } + + return .discovering + } + + var liveScanStatusTitle: String { + guard isScanning else { + return scanStatus + } + + if indexedItemCount == 0 { + return "Scanning Minecraft library..." + } + + let discoveryIsComplete = (scanProgress ?? 0) >= 0.65 + + if !discoveryIsComplete { + return "Discovering items..." + } + + if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 { + return "Preparing size calculations..." + } + + if scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..." { + return scanStatus + } + + switch scanPhase { + case .discovering, .metadata, .previews: + return "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." + case .sizing: + return "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." + case .completed: + return indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(indexedDetailCount) items." + case .idle: + return scanStatus + } + } + + var showsIndeterminateScanActivityIndicator: Bool { + guard isScanning else { + return false + } + + let discoveryIsComplete = (scanProgress ?? 0) >= 0.65 + if !discoveryIsComplete { + return true + } + + if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 { + return true + } + + return scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..." + } + func rawItem(withID itemID: URL) -> MinecraftContentItem? { rawItems.first(where: { $0.id == itemID }) } diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 5927a53..7bd120e 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -54,8 +54,13 @@ final class SourceLibrary: ObservableObject { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 private static let minimumVisibleScanDuration: TimeInterval = 0.8 + private static let automaticSyncDebounce: TimeInterval = 0.75 + private static let localSourceRefreshInterval: TimeInterval = 4.0 private static let connectedDeviceRefreshInterval: TimeInterval = 2.0 private static let connectedDeviceRefreshIntervalWhileScanning: TimeInterval = 5.0 + private static let footerRefreshDebounce: TimeInterval = 0.15 + private static let usbConnectedDeviceAutoRefreshInterval: TimeInterval = 45.0 + private static let networkConnectedDeviceAutoRefreshInterval: TimeInterval = 120.0 private static let usbConnectedDeviceDiscoveryCacheTTL: TimeInterval = 60.0 private static let networkConnectedDeviceDiscoveryCacheTTL: TimeInterval = 180.0 private static let performanceLogger = Logger( @@ -75,8 +80,11 @@ final class SourceLibrary: ObservableObject { @Published private(set) var isRestoringPersistedSources = true private var scanTasks: [URL: Task] = [:] + private var automaticSyncTasks: [URL: Task] = [:] private var connectedDeviceRefreshTask: Task? + private var localSourceRefreshTask: Task? private var footerResetTask: Task? + private var footerRefreshTask: Task? private let persistenceStore: SourcePersistenceStore private let sourceAccessMethod: SourceAccessMethod private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? @@ -101,6 +109,10 @@ final class SourceLibrary: ObservableObject { await self?.restorePersistedSources() } + localSourceRefreshTask = Task { [weak self] in + await self?.runLocalSourceRefreshLoop() + } + if connectedDeviceAccessMethod != nil { connectedDeviceRefreshTask = Task { [weak self] in await self?.runConnectedDeviceRefreshLoop() @@ -110,7 +122,10 @@ final class SourceLibrary: ObservableObject { deinit { connectedDeviceRefreshTask?.cancel() + localSourceRefreshTask?.cancel() footerResetTask?.cancel() + footerRefreshTask?.cancel() + automaticSyncTasks.values.forEach { $0.cancel() } scanTasks.values.forEach { $0.cancel() } } @@ -130,8 +145,17 @@ final class SourceLibrary: ObservableObject { isShuttingDown = true connectedDeviceRefreshTask?.cancel() connectedDeviceRefreshTask = nil + localSourceRefreshTask?.cancel() + localSourceRefreshTask = nil footerResetTask?.cancel() footerResetTask = nil + footerRefreshTask?.cancel() + footerRefreshTask = nil + + for task in automaticSyncTasks.values { + task.cancel() + } + automaticSyncTasks.removeAll() for task in scanTasks.values { task.cancel() @@ -286,6 +310,8 @@ final class SourceLibrary: ObservableObject { return } + automaticSyncTasks[sourceID]?.cancel() + automaticSyncTasks[sourceID] = nil scanTasks[sourceID]?.cancel() let task = Task { [weak self] in @@ -299,14 +325,20 @@ final class SourceLibrary: ObservableObject { scanTasks[sourceID] = task } + private var hasActiveScan: Bool { + sources.contains(where: \.isScanning) + } + + private var hasActiveConnectedDeviceScan: Bool { + sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice } + } + private func scanSource(withID sourceID: URL) async { var workerTasks: [Task] = [] - var previewWorkerTasks: [Task] = [] var sizeWorkerTasks: [Task] = [] let scanStartTime = Date() defer { workerTasks.forEach { $0.cancel() } - previewWorkerTasks.forEach { $0.cancel() } sizeWorkerTasks.forEach { $0.cancel() } scanTasks[sourceID] = nil } @@ -325,6 +357,8 @@ final class SourceLibrary: ObservableObject { source.scanProgress = nil source.indexedItemCount = 0 source.indexedDetailCount = 0 + source.previewLoadedCount = 0 + source.sizeLoadedCount = 0 } refreshSidebarFooterState() @@ -353,10 +387,7 @@ final class SourceLibrary: ObservableObject { do { let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL) let enrichmentQueue = EnrichmentWorkQueue() - let previewQueue = EnrichmentWorkQueue() - let sizeQueue = EnrichmentWorkQueue() let enrichmentWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.enrichmentWorkerCount - let previewWorkerCount = source.origin.kind == .connectedDevice ? 1 : 1 let sizeWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.sizeWorkerCount workerTasks = (0.. Bool { + if currentSource.rawItems.count > previousSource.rawItems.count { + return true } + if currentSource.indexedDetailCount > previousSource.indexedDetailCount { + return true + } + + if currentSource.previewLoadedCount > previousSource.previewLoadedCount { + return true + } + + if currentSource.sizeLoadedCount > previousSource.sizeLoadedCount { + return true + } + + return false + } + private func performanceContext(for source: MinecraftSource) -> String { switch source.origin { case .localFolder: @@ -899,9 +1016,15 @@ final class SourceLibrary: ObservableObject { source.worldPackRelationships = snapshot.worldPackRelationships source.indexedItemCount = snapshot.indexedItemCount source.indexedDetailCount = snapshot.indexedDetailCount + source.previewLoadedCount = snapshot.previewLoadedCount + source.sizeLoadedCount = snapshot.sizeLoadedCount source.scanStatus = snapshot.scanStatus source.scanProgress = snapshot.scanProgress source.isScanning = snapshot.isScanning + source.previewStageElapsed = snapshot.previewStageElapsed + source.previewStageDuration = snapshot.previewStageDuration + source.sizeStageElapsed = snapshot.sizeStageElapsed + source.sizeStageDuration = snapshot.sizeStageDuration source.lastScanDate = snapshot.lastScanDate } } @@ -1037,6 +1160,15 @@ final class SourceLibrary: ObservableObject { private func runConnectedDeviceRefreshLoop() async { while !Task.isCancelled && !isShuttingDown { + if hasActiveConnectedDeviceScan { + do { + try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshIntervalWhileScanning)) + } catch { + return + } + continue + } + await refreshConnectedDevices() do { @@ -1052,11 +1184,86 @@ final class SourceLibrary: ObservableObject { } } + private func runLocalSourceRefreshLoop() async { + while !Task.isCancelled && !isShuttingDown { + if hasActiveScan { + do { + try await Task.sleep(for: .seconds(Self.localSourceRefreshInterval)) + } catch { + return + } + continue + } + + await refreshLocalSources() + + do { + try await Task.sleep(for: .seconds(Self.localSourceRefreshInterval)) + } catch { + return + } + } + } + + private func refreshLocalSources() async { + guard !hasActiveScan else { + return + } + + let localSourceIDs = sources + .filter { $0.origin.kind == .localFolder } + .map(\.id) + + for sourceID in localSourceIDs { + guard !Task.isCancelled, !isShuttingDown else { + return + } + + guard let currentSource = source(withID: sourceID) else { + continue + } + + let availability = await sourceAccessMethod.availability(for: currentSource) + let transition = updateAvailability(for: sourceID, to: availability) + + guard let refreshedSource = source(withID: sourceID) else { + continue + } + + if transition.becameAvailable { + queueAutomaticSync( + for: sourceID, + reason: refreshedSource.hasCachedContent + ? "Folder available. Refreshing cached library..." + : "Folder available. Scanning Minecraft library..." + ) + continue + } + + guard refreshedSource.availability == SourceAvailability.available, !refreshedSource.isScanning else { + continue + } + + if sourceNeedsReconcile(refreshedSource) { + queueAutomaticSync( + for: sourceID, + reason: refreshedSource.hasCachedContent + ? "Detected changes. Refreshing cached library..." + : "Scanning Minecraft library..." + ) + } + } + } + private func refreshConnectedDevices() async { guard !isShuttingDown else { return } + guard !hasActiveConnectedDeviceScan else { + return + } + guard let connectedDeviceAccessMethod else { return } @@ -1086,14 +1293,17 @@ final class SourceLibrary: ObservableObject { cachedDeviceDiscoveryByUDID = cachedDeviceDiscoveryByUDID.filter { currentDeviceUDIDs.contains($0.key) } for device in devices { - if let matchedSourceID = knownConnectedDeviceSourceID(for: device) { - matchedSourceIDs.insert(matchedSourceID) + let knownSourceIDs = knownConnectedDeviceSourceIDs(for: device) + if !knownSourceIDs.isEmpty { + matchedSourceIDs.formUnion(knownSourceIDs) let cachedContainers = cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? [] - refreshMatchedConnectedDeviceSource( - sourceID: matchedSourceID, - device: device, - containers: cachedContainers - ) + for sourceID in knownSourceIDs { + refreshMatchedConnectedDeviceSource( + sourceID: sourceID, + device: device, + containers: cachedContainers + ) + } continue } @@ -1154,6 +1364,7 @@ final class SourceLibrary: ObservableObject { let shouldDisplayEntry = matchedSourceID == nil + && !hasKnownConnectedDeviceSource(for: device) && (!containers.isEmpty || device.trustState != .trusted) if shouldDisplayEntry { @@ -1251,20 +1462,18 @@ final class SourceLibrary: ObservableObject { return nil } - private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? { - let matchingSourceIDs = sources.compactMap { source -> URL? in + private func knownConnectedDeviceSourceIDs(for device: ConnectedDevice) -> [URL] { + sources.compactMap { source -> URL? in guard case .connectedDevice(let expectedDevice, _) = source.origin else { return nil } return expectedDevice.udid == device.udid ? source.id : nil } + } - guard matchingSourceIDs.count == 1 else { - return nil - } - - return matchingSourceIDs.first + private func hasKnownConnectedDeviceSource(for device: ConnectedDevice) -> Bool { + !knownConnectedDeviceSourceIDs(for: device).isEmpty } private func refreshMatchedConnectedDeviceSource( @@ -1272,6 +1481,7 @@ final class SourceLibrary: ObservableObject { device: ConnectedDevice, containers: [DeviceAppContainer] ) { + let nextAvailability = availability(for: device, hasMinecraftContainer: true) updateSource(sourceID) { source in guard case .connectedDevice(_, let previousContainer) = source.origin else { return @@ -1284,24 +1494,38 @@ final class SourceLibrary: ObservableObject { source.origin = .connectedDevice(device: device, container: resolvedContainer) source.displayName = "\(device.name) • \(resolvedContainer.appName)" source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) - source.availability = availability(for: device, hasMinecraftContainer: true) } + let transition = updateAvailability(for: sourceID, to: nextAvailability) persistSourceIfAvailable(withID: sourceID) + + guard let source = source(withID: sourceID), source.availability == .available else { + return + } + + if transition.becameAvailable { + queueAutomaticSync( + for: sourceID, + reason: source.hasCachedContent + ? "Device available. Refreshing cached library..." + : "Device available. Scanning Minecraft library..." + ) + return + } + + if shouldRefreshConnectedDeviceSource(source, device: device) { + queueAutomaticSync(for: sourceID, reason: "Refreshing device library...") + } } private func markAllConnectedDeviceSourcesDisconnected() { for source in sources where source.origin.kind == .connectedDevice { - updateSource(source.id) { source in - source.availability = .disconnected - } + _ = updateAvailability(for: source.id, to: .disconnected) } } private func markDisconnectedConnectedDeviceSources(excluding matchedSourceIDs: Set) { for source in sources where source.origin.kind == .connectedDevice && !matchedSourceIDs.contains(source.id) { - updateSource(source.id) { source in - source.availability = .disconnected - } + _ = updateAvailability(for: source.id, to: .disconnected) } } @@ -1346,6 +1570,8 @@ final class SourceLibrary: ObservableObject { source.rawItems = await restoreCachedImages(in: record.rawItems) source.indexedItemCount = record.rawItems.count source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count + source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count + source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count source.lastScanDate = record.lastScanDate source.snapshot = record.snapshot @@ -1354,6 +1580,8 @@ final class SourceLibrary: ObservableObject { updateSource(source.id) { source in source.displayItems = source.displayItems.sorted(by: WorldScanner.sortItems) + source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count + source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count source.scanStatus = source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items." @@ -1362,13 +1590,9 @@ final class SourceLibrary: ObservableObject { sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } - for record in records { - if sourceNeedsRescan(record) { - startScan(for: record.sourceID) - } - } - await refreshConnectedDevices() + await refreshLocalSources() + scheduleRestoredSourceRefreshes(records: records) } private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] { @@ -1449,6 +1673,81 @@ final class SourceLibrary: ObservableObject { return false } + private func sourceNeedsReconcile(_ source: MinecraftSource) -> Bool { + guard source.accessDescriptor.refreshStrategy == .eagerFullScan else { + return source.rawItems.isEmpty + } + + guard let snapshot = source.snapshot else { + return true + } + + let fileManager = FileManager.default + let sourceURL = source.folderURL + + guard fileManager.fileExists(atPath: sourceURL.path) else { + return false + } + + let currentCollections = Dictionary(uniqueKeysWithValues: currentCollectionSnapshots(for: sourceURL).map { ($0.folderName, $0) }) + let persistedCollections = Dictionary(uniqueKeysWithValues: snapshot.collectionSnapshots.map { ($0.folderName, $0) }) + + if currentCollections.count != persistedCollections.count { + return true + } + + for (folderName, persistedCollection) in persistedCollections { + guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { + return true + } + } + + for itemSnapshot in snapshot.itemSnapshots { + let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true) + guard fileManager.fileExists(atPath: itemURL.path) else { + return true + } + + let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + if modifiedDate != itemSnapshot.modifiedDate { + return true + } + } + + return false + } + + private func scheduleRestoredSourceRefreshes(records: [PersistedSourceRecord]) { + let persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.sourceID, $0) }) + + for source in sources { + guard source.availability == .available else { + continue + } + + switch source.origin.kind { + case .localFolder: + if let record = persistedRecordsByID[source.id], sourceNeedsRescan(record) || sourceNeedsReconcile(source) { + queueAutomaticSync( + for: source.id, + reason: source.hasCachedContent + ? "Refreshing cached library..." + : "Scanning Minecraft library..." + ) + } + case .connectedDevice: + if source.rawItems.isEmpty || source.lastScanDate == nil { + queueAutomaticSync( + for: source.id, + reason: source.hasCachedContent + ? "Refreshing cached library..." + : "Scanning Minecraft library..." + ) + } + } + } + } + private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { let fileManager = FileManager.default @@ -1536,6 +1835,54 @@ final class SourceLibrary: ObservableObject { } } + private func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) { + guard !isShuttingDown else { + return + } + + guard let source = source(withID: sourceID), source.availability == .available else { + return + } + + if source.isScanning { + return + } + + let resolvedDebounce = debounce ?? Self.automaticSyncDebounce + + automaticSyncTasks[sourceID]?.cancel() + updateSource(sourceID) { source in + guard !source.isScanning else { + return + } + + source.scanError = nil + if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { + source.scanDiagnostic = nil + } + source.scanStatus = reason + source.scanProgress = nil + } + + let task = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(resolvedDebounce)) + } catch { + return + } + + guard let self, !Task.isCancelled else { + return + } + + await MainActor.run { + self.startScan(for: sourceID) + } + } + + automaticSyncTasks[sourceID] = task + } + private func purgeCachedArtifacts(for source: MinecraftSource) { Task.detached(priority: .utility) { [sourceAccessMethod] in await sourceAccessMethod.purgeCachedArtifacts(for: source) @@ -1555,6 +1902,9 @@ final class SourceLibrary: ObservableObject { } private func refreshSidebarFooterState() { + footerRefreshTask?.cancel() + footerRefreshTask = nil + if isRestoringPersistedSources { cancelFooterReset() sidebarFooterState = SidebarFooterState( @@ -1570,18 +1920,23 @@ final class SourceLibrary: ObservableObject { let scanningSources = sources.filter(\.isScanning) if let source = scanningSources.first { cancelFooterReset() - let title = source.scanStatus.isEmpty ? "Scanning Minecraft library..." : source.scanStatus + let title = source.liveScanStatusTitle.isEmpty + ? "Scanning Minecraft library..." + : source.liveScanStatusTitle let subtitle: String let detail: String? if source.indexedItemCount > 0 { subtitle = source.displayName - let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count - let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count - if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") { - detail = "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated" - } else if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") { - detail = "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded" - } else { + switch source.scanPhase { + case .discovering: + detail = "\(source.indexedItemCount) items found" + case .metadata: + detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" + case .previews: + detail = "\(source.previewLoadedCount) of \(source.indexedItemCount) previews loaded" + case .sizing: + detail = "\(source.sizeLoadedCount) of \(source.indexedItemCount) sizes calculated" + case .completed, .idle: detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" } } else { @@ -1614,6 +1969,84 @@ final class SourceLibrary: ObservableObject { sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, detail: nil, revealURL: nil) } + private func scheduleSidebarFooterRefresh() { + guard !isRestoringPersistedSources else { + refreshSidebarFooterState() + return + } + + footerRefreshTask?.cancel() + footerRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.footerRefreshDebounce)) + guard let self, !Task.isCancelled else { + return + } + + self.refreshSidebarFooterState() + } + } + + @discardableResult + private func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) { + let previousAvailability = source(withID: sourceID)?.availability ?? .unknown + let becameAvailable = previousAvailability != .available && newAvailability == .available + + updateSource(sourceID) { source in + source.availability = newAvailability + + guard !source.isScanning else { + return + } + + if newAvailability == .available { + source.scanError = nil + if isCachedAvailabilityDiagnostic(source.scanDiagnostic) { + source.scanDiagnostic = nil + } + if becameAvailable || source.scanStatus.isEmpty { + source.scanStatus = source.indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(source.indexedDetailCount) items." + } + } else { + source.scanError = nil + source.scanProgress = nil + source.scanStatus = source.availabilityDisplayText + source.scanDiagnostic = source.cachedAvailabilityDetailText + } + } + + return (previousAvailability, becameAvailable) + } + + private func isCachedAvailabilityDiagnostic(_ diagnostic: String?) -> Bool { + guard let diagnostic else { + return false + } + + return diagnostic.localizedCaseInsensitiveContains("showing cached results") + } + + private func shouldRefreshConnectedDeviceSource(_ source: MinecraftSource, device: ConnectedDevice) -> Bool { + guard !source.isScanning else { + return false + } + + guard let lastScanDate = source.lastScanDate else { + return true + } + + let refreshInterval: TimeInterval + switch device.connection { + case .usb: + refreshInterval = Self.usbConnectedDeviceAutoRefreshInterval + case .network: + refreshInterval = Self.networkConnectedDeviceAutoRefreshInterval + } + + return Date().timeIntervalSince(lastScanDate) >= refreshInterval + } + private func cancelFooterReset() { footerResetTask?.cancel() footerResetTask = nil @@ -1843,9 +2276,15 @@ private struct SourceIndexSnapshot { let worldPackRelationships: [WorldPackRelationship] let indexedItemCount: Int let indexedDetailCount: Int + let previewLoadedCount: Int + let sizeLoadedCount: Int let scanStatus: String let scanProgress: Double? let isScanning: Bool + let previewStageElapsed: TimeInterval? + let previewStageDuration: TimeInterval? + let sizeStageElapsed: TimeInterval? + let sizeStageDuration: TimeInterval? let lastScanDate: Date? } @@ -1864,10 +2303,15 @@ private actor SourceIndexActor { private var indexedItemCount = 0 private var indexedDetailCount = 0 private var previewLoadedCount = 0 + private var sizeLoadedCount = 0 private var discoveryFinished = false private var metadataFinished = false private var previewsFinished = false private var sizesFinished = false + private var previewStageStartedAt: Date? + private var previewStageFinishedAt: Date? + private var sizeStageStartedAt: Date? + private var sizeStageFinishedAt: Date? private var lastPublishedAt: Date? init(sourceID: URL, folderURL: URL) { @@ -1901,9 +2345,13 @@ private actor SourceIndexActor { return nil } + let wasSizeLoaded = current.sizeLoaded current.sizeBytes = item.sizeBytes current.sizeLoaded = item.sizeLoaded itemsByID[item.id] = current + if item.sizeLoaded, wasSizeLoaded != true { + sizeLoadedCount += 1 + } return snapshotIfNeeded() } @@ -1925,6 +2373,7 @@ private actor SourceIndexActor { func markMetadataFinished() -> SourceIndexSnapshot? { discoveryFinished = true metadataFinished = true + previewStageStartedAt = previewStageStartedAt ?? Date() return buildSnapshot(force: true) } @@ -1932,6 +2381,10 @@ private actor SourceIndexActor { discoveryFinished = true metadataFinished = true previewsFinished = true + let now = Date() + previewStageStartedAt = previewStageStartedAt ?? now + previewStageFinishedAt = previewStageFinishedAt ?? now + sizeStageStartedAt = sizeStageStartedAt ?? now return buildSnapshot(force: true) } @@ -1940,9 +2393,17 @@ private actor SourceIndexActor { metadataFinished = true previewsFinished = true sizesFinished = true + let now = Date() + previewStageFinishedAt = previewStageFinishedAt ?? now + sizeStageStartedAt = sizeStageStartedAt ?? now + sizeStageFinishedAt = sizeStageFinishedAt ?? now return buildSnapshot(force: true) } + func currentItems() -> [MinecraftContentItem] { + orderedItemIDs.compactMap { itemsByID[$0] } + } + private func snapshotIfNeeded() -> SourceIndexSnapshot? { buildSnapshot(force: false) } @@ -1965,8 +2426,15 @@ private actor SourceIndexActor { ) let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) - let sizeLoadedCount = rawItems.filter(\.sizeLoaded).count let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) + let previewStageElapsed = previewStageStartedAt.map { now.timeIntervalSince($0) } + let previewStageDuration = previewStageStartedAt.flatMap { startedAt in + previewStageFinishedAt.map { $0.timeIntervalSince(startedAt) } + } + let sizeStageElapsed = sizeStageStartedAt.map { now.timeIntervalSince($0) } + let sizeStageDuration = sizeStageStartedAt.flatMap { startedAt in + sizeStageFinishedAt.map { $0.timeIntervalSince(startedAt) } + } let scanStatus: String if !discoveryFinished { @@ -1983,9 +2451,15 @@ private actor SourceIndexActor { worldPackRelationships: [], indexedItemCount: indexedItemCount, indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, scanStatus: scanStatus, scanProgress: nil, isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, lastScanDate: nil ) } @@ -2004,17 +2478,27 @@ private actor SourceIndexActor { worldPackRelationships: [], indexedItemCount: indexedItemCount, indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, scanStatus: scanStatus, scanProgress: progressAfterDiscovery(metadataFraction), isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, lastScanDate: nil ) } if !previewsFinished { - scanStatus = indexedItemCount == 0 - ? "No Minecraft items found." - : "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." + if indexedItemCount == 0 { + scanStatus = "No Minecraft items found." + } else if previewLoadedCount == 0 { + scanStatus = "Preparing previews..." + } else { + scanStatus = "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." + } return SourceIndexSnapshot( displayItems: dedupedDisplayItems, @@ -2025,9 +2509,15 @@ private actor SourceIndexActor { worldPackRelationships: [], indexedItemCount: indexedItemCount, indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, scanStatus: scanStatus, scanProgress: progressAfterMetadata(previewFraction), isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, lastScanDate: nil ) } @@ -2108,9 +2598,13 @@ private actor SourceIndexActor { } if !sizesFinished { - scanStatus = indexedItemCount == 0 - ? "No Minecraft items found." - : "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." + if indexedItemCount == 0 { + scanStatus = "No Minecraft items found." + } else if sizeLoadedCount == 0 { + scanStatus = "Preparing size calculations..." + } else { + scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." + } } else { scanStatus = indexedItemCount == 0 ? "No Minecraft items found." @@ -2128,9 +2622,15 @@ private actor SourceIndexActor { worldPackRelationships: worldRelationships, indexedItemCount: indexedItemCount, indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, scanStatus: scanStatus, scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction), isScanning: !sizesFinished, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, lastScanDate: sizesFinished ? now : nil ) } diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index 737c23c..4d7faab 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -52,7 +52,7 @@ struct SourcesSidebarView: View { connectedDeviceSectionRows(for: entry) } } header: { - SidebarSourcesSectionHeaderView(title: "Connected Devices") + SidebarSourcesSectionHeaderView(title: "Available Devices") } } } @@ -153,7 +153,6 @@ private struct SourceHeaderRow: View { let source: MinecraftSource let isSelected: Bool let onSelect: () -> Void - @State private var isPresentingStatusPopover = false @State private var isHovering = false var body: some View { @@ -172,19 +171,13 @@ private struct SourceHeaderRow: View { SourceConnectionBadge(connection: connection) } - if showsStatusButton { - Button { - isPresentingStatusPopover = true - } label: { - statusIndicator - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(scanStatusHelpText) - .popover(isPresented: $isPresentingStatusPopover, arrowEdge: .top) { - SourceStatusPopover(source: source) - } + if let availabilityBadgeText { + SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis) + } + + if showsStatusIndicator { + statusIndicator + .frame(width: 24, height: 24) } } .padding(.horizontal, 10) @@ -203,18 +196,6 @@ private struct SourceHeaderRow: View { return device.connection } - private var scanStatusHelpText: String { - if let scanError = source.scanError, !scanError.isEmpty { - return scanError - } - - if !source.scanStatus.isEmpty { - return source.scanStatus - } - - return "Scanning library…" - } - private var headerSymbolName: String { switch source.origin { case .localFolder: @@ -225,7 +206,30 @@ private struct SourceHeaderRow: View { } private var titleColor: Color { - isSelected ? .primary : .secondary + if source.availability != .available && !isSelected { + return .secondary + } + + return isSelected ? Color.primary : .secondary + } + + private var availabilityBadgeText: String? { + if source.isOfflineCached { + return "Cached" + } + + switch source.availability { + case .limited: + return "Limited" + case .unavailable, .disconnected: + return "Offline" + case .available, .unknown: + return nil + } + } + + private var availabilityBadgeEmphasis: Bool { + source.availability == .limited } private var backgroundStyle: AnyShapeStyle { @@ -249,6 +253,12 @@ private struct SourceHeaderRow: View { ProgressView() .controlSize(.small) } + } else if source.availability == .limited { + Image(systemName: "lock.circle") + .foregroundStyle(.secondary) + } else if source.availability != .available { + Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle") + .foregroundStyle(.secondary) } else if source.scanError != nil { Image(systemName: "exclamationmark.circle") .foregroundStyle(.secondary) @@ -258,8 +268,8 @@ private struct SourceHeaderRow: View { } } - private var showsStatusButton: Bool { - source.isScanning || source.scanError != nil + private var showsStatusIndicator: Bool { + source.isScanning || source.scanError != nil || source.availability != .available } } @@ -296,92 +306,21 @@ private struct SourceConnectionBadge: View { } } -private struct SourceStatusPopover: View { - let source: MinecraftSource +private struct SourceAvailabilityBadge: View { + let text: String + let emphasis: Bool var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 8) { - if !source.isScanning, source.scanError != nil { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } else if !source.isScanning { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } - - Text(titleText) - .font(.headline) - } - - if source.isScanning, let scanProgress = source.scanProgress { - ProgressView(value: scanProgress, total: 1) - } - - if let subtitleText { - Text(subtitleText) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - if let detailText { - Text(detailText) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(width: 280, alignment: .leading) - .padding(14) + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(emphasis ? Color.appAccent : .secondary) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(backgroundColor, in: Capsule()) } - private var titleText: String { - if let scanError = source.scanError, !scanError.isEmpty { - return "Scan Failed" - } - - if !source.scanStatus.isEmpty { - return source.scanStatus - } - - return "Scanning Minecraft library..." - } - - private var subtitleText: String? { - if let scanError = source.scanError, !scanError.isEmpty { - return scanError - } - - if source.indexedItemCount > 0 { - return source.displayName - } - - return "Searching \(source.displayName)" - } - - private var detailText: String? { - if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty { - return diagnostic - } - - guard source.scanError == nil, source.indexedItemCount > 0 else { - return nil - } - - if source.isScanning, let scanProgress = source.scanProgress { - let percentage = Int((scanProgress * 100).rounded()) - let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count - let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count - if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") { - return "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated • \(percentage)%" - } - if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") { - return "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded • \(percentage)%" - } - - return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed • \(percentage)%" - } - - return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" + private var backgroundColor: Color { + emphasis ? Color.appAccent.opacity(0.14) : .secondary.opacity(0.12) } } @@ -473,8 +412,8 @@ private struct ConnectedDeviceRow: View { switch entry.device.trustState { case .trusted: - if entry.hasMinecraftContainer, let container = entry.minecraftContainer { - return "Minecraft found in \(container.appName)" + if entry.hasMinecraftContainer { + return "Ready to add Minecraft library" } return "No Minecraft source found" diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift index 1c4749c..7adb090 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift @@ -49,11 +49,22 @@ struct AppleMobileMinecraftItemMetadataSummary: Sendable { let packReferences: [AppleMobilePackReferenceSummary] } +struct AppleMobileMinecraftIconSummary: Sendable { + let relativePath: String + let iconFileName: String + let data: Data +} + struct AppleMobileDevicePathMetrics: Sendable { let sizeBytes: Int64? let modifiedDate: Date? } +struct AppleMobileDevicePathMetricsSummary: Sendable { + let relativePath: String + let metrics: AppleMobileDevicePathMetrics +} + actor AppleMobileDeviceOperationLimiter { static let shared = AppleMobileDeviceOperationLimiter() @@ -387,6 +398,63 @@ enum AppleMobileDeviceAccess { } } + static func minecraftIconBatch( + deviceIdentifier: String, + bundleIdentifier: String, + relativePath: String, + items: [AppleMobileMinecraftLibraryItemSummary] + ) async throws -> [AppleMobileMinecraftIconSummary] { + try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) { + try await Task.detached(priority: .userInitiated) { + let requestItems = items.map { item in + [ + "contentType": item.contentType, + "relativePath": item.relativePath + ] + } + + var error: NSError? + guard let response = WMMCopyConnectedDeviceMinecraftIconBatch( + deviceIdentifier, + bundleIdentifier, + relativePath, + requestItems, + &error + ) else { + throw error ?? NSError( + domain: "AppleMobileDeviceAccess", + code: 12, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice icon batch failed."] + ) + } + + guard let rawItems = response["items"] as? [[String: Any]] else { + throw NSError( + domain: "AppleMobileDeviceAccess", + code: 13, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice icon batch returned an unexpected payload."] + ) + } + + return rawItems.compactMap { item in + guard + let relativePath = item["relativePath"] as? String, + let iconFileName = item["iconFileName"] as? String, + let data = item["data"] as? Data + else { + return nil + } + + return AppleMobileMinecraftIconSummary( + relativePath: relativePath, + iconFileName: iconFileName, + data: data + ) + } + }.value + } + } + static func pathMetrics( deviceIdentifier: String, bundleIdentifier: String, @@ -429,6 +497,65 @@ enum AppleMobileDeviceAccess { } } + static func pathMetricsBatch( + deviceIdentifier: String, + bundleIdentifier: String, + relativePaths: [String] + ) async throws -> [AppleMobileDevicePathMetricsSummary] { + try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) { + try await Task.detached(priority: .utility) { + var error: NSError? + guard let response = WMMCopyConnectedDeviceAppPathMetricsBatch( + deviceIdentifier, + bundleIdentifier, + relativePaths, + &error + ) else { + throw error ?? NSError( + domain: "AppleMobileDeviceAccess", + code: 14, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics batch lookup failed."] + ) + } + + guard let rawItems = response["items"] as? [[String: Any]] else { + throw NSError( + domain: "AppleMobileDeviceAccess", + code: 15, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics batch returned an unexpected payload."] + ) + } + + return rawItems.compactMap { item in + guard let relativePath = item["relativePath"] as? String else { + return nil + } + + let rawSize = item["sizeBytes"] + let sizeBytes: Int64? + switch rawSize { + case let number as NSNumber: + sizeBytes = number.int64Value + case let value as Int64: + sizeBytes = value + case let value as Int: + sizeBytes = Int64(value) + default: + sizeBytes = nil + } + + return AppleMobileDevicePathMetricsSummary( + relativePath: relativePath, + metrics: AppleMobileDevicePathMetrics( + sizeBytes: sizeBytes, + modifiedDate: item["modifiedDate"] as? Date + ) + ) + } + }.value + } + } + nonisolated private static func flexibleBool(from value: Any?) -> Bool { switch value { case let value as Bool: diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h index ed6b088..a3ecb0a 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h @@ -60,6 +60,15 @@ WMMCopyConnectedDeviceMinecraftMetadataBatch( NSError **error ); +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyConnectedDeviceMinecraftIconBatch( + NSString *deviceIdentifier, + NSString *bundleIdentifier, + NSString *relativePath, + NSArray *> *items, + NSError **error +); + FOUNDATION_EXPORT NSData * _Nullable WMMCopyConnectedDeviceAppFileData( NSString *deviceIdentifier, @@ -76,6 +85,14 @@ WMMCopyConnectedDeviceAppPathMetrics( NSError **error ); +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyConnectedDeviceAppPathMetricsBatch( + NSString *deviceIdentifier, + NSString *bundleIdentifier, + NSArray *relativePaths, + NSError **error +); + FOUNDATION_EXPORT BOOL WMMCopyConnectedDeviceAppSubtreeToLocalDirectory( NSString *deviceIdentifier, diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m index 524bac9..c7ddcdb 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m @@ -13,6 +13,23 @@ NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain"; +static BOOL WMMMobileDeviceVerboseLoggingEnabled(void) { + static BOOL initialized = NO; + static BOOL enabled = NO; + if (!initialized) { + NSString *value = [[[NSProcessInfo processInfo] environment][@"WMM_MOBILEDEVICE_VERBOSE_LOGGING"] lowercaseString]; + enabled = [value isEqualToString:@"1"] || [value isEqualToString:@"true"] || [value isEqualToString:@"yes"]; + initialized = YES; + } + return enabled; +} + +#define WMMBridgeLog(...) do { \ + if (WMMMobileDeviceVerboseLoggingEnabled()) { \ + NSLog(__VA_ARGS__); \ + } \ +} while (0) + typedef struct am_device *AMDeviceRef; typedef struct am_device_notification *AMDeviceNotificationRef; typedef struct amd_service_connection *AMDServiceConnectionRef; @@ -455,7 +472,7 @@ static void WMMLogDeviceTransportDiagnostics( values[key] = value.length > 0 ? value : @""; } - NSLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values); + WMMBridgeLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values); } static BOOL WMMConnectAndValidateDevice( @@ -576,7 +593,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( *backingServiceConnection = NULL; } - NSLog(@"[HouseArrest] Trying AMDeviceCreateHouseArrestService for %@", bundleIdentifier); + WMMBridgeLog(@"[HouseArrest] Trying AMDeviceCreateHouseArrestService for %@", bundleIdentifier); AFCConnectionRef directConnection = NULL; int directStatus = functions->AMDeviceCreateHouseArrestService( device, @@ -584,7 +601,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( NULL, &directConnection ); - NSLog(@"[HouseArrest] AMDeviceCreateHouseArrestService returned %d connection=%p", directStatus, directConnection); + WMMBridgeLog(@"[HouseArrest] AMDeviceCreateHouseArrestService returned %d connection=%p", directStatus, directConnection); if (directStatus == 0 && directConnection != NULL) { return directConnection; } @@ -594,7 +611,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( [failures addObject:[NSString stringWithFormat:@"AMDeviceCreateHouseArrestService returned %d", directStatus]]; for (NSString *command in commands) { - NSLog(@"[HouseArrest] Starting %@ for %@", command, bundleIdentifier); + WMMBridgeLog(@"[HouseArrest] Starting %@ for %@", command, bundleIdentifier); AMDServiceConnectionRef serviceConnection = NULL; int startStatus = functions->AMDeviceSecureStartService( device, @@ -603,14 +620,14 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( &serviceConnection ); if (startStatus != 0 || serviceConnection == NULL) { - NSLog(@"[HouseArrest] %@ service start failed: %d", command, startStatus); + WMMBridgeLog(@"[HouseArrest] %@ service start failed: %d", command, startStatus); [failures addObject:[NSString stringWithFormat:@"%@ service start failed (%d)", command, startStatus]]; continue; } int socket = functions->AMDServiceConnectionGetSocket(serviceConnection); void *secureContext = functions->AMDServiceConnectionGetSecureIOContext(serviceConnection); - NSLog(@"[HouseArrest] %@ service connection socket=%d secureContext=%p", command, socket, secureContext); + WMMBridgeLog(@"[HouseArrest] %@ service connection socket=%d secureContext=%p", command, socket, secureContext); NSDictionary *request = @{ @"Command": command, @@ -622,7 +639,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( (__bridge CFPropertyListRef)request, 100 ); - NSLog(@"[HouseArrest] %@ send returned %d", command, sent); + WMMBridgeLog(@"[HouseArrest] %@ send returned %d", command, sent); if (sent != 0) { [failures addObject:[NSString stringWithFormat:@"%@ request failed to send (%d)", command, sent]]; functions->AMDServiceConnectionInvalidate(serviceConnection); @@ -635,7 +652,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( &response, 0 ); - NSLog(@"[HouseArrest] %@ receive returned %d", command, received); + WMMBridgeLog(@"[HouseArrest] %@ receive returned %d", command, received); if (received != 0 || response == NULL) { [failures addObject:[NSString stringWithFormat:@"%@ response could not be read (%d)", command, received]]; functions->AMDServiceConnectionInvalidate(serviceConnection); @@ -643,7 +660,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( } NSDictionary *responseDictionary = CFBridgingRelease(response); - NSLog(@"[HouseArrest] %@ response: %@", command, responseDictionary); + WMMBridgeLog(@"[HouseArrest] %@ response: %@", command, responseDictionary); NSString *status = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Status"] : nil; if ([status isKindOfClass:[NSString class]] && [status isEqualToString:@"Complete"]) { AFCConnectionRef afcConnection = WMMCreateAFCConnectionFromServiceConnection(functions, serviceConnection); @@ -651,22 +668,22 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( if (backingServiceConnection != NULL) { *backingServiceConnection = serviceConnection; } - NSLog(@"[HouseArrest] %@ completed and AFC initialized", command); + WMMBridgeLog(@"[HouseArrest] %@ completed and AFC initialized", command); return afcConnection; } functions->AMDServiceConnectionInvalidate(serviceConnection); - NSLog(@"[HouseArrest] %@ completed but AFC initialization failed", command); + WMMBridgeLog(@"[HouseArrest] %@ completed but AFC initialization failed", command); [failures addObject:[NSString stringWithFormat:@"%@ succeeded but AFC initialization failed", command]]; break; } NSString *serviceError = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Error"] : nil; if ([serviceError isKindOfClass:[NSString class]] && serviceError.length > 0) { - NSLog(@"[HouseArrest] %@ rejected with error: %@", command, serviceError); + WMMBridgeLog(@"[HouseArrest] %@ rejected with error: %@", command, serviceError); [failures addObject:[NSString stringWithFormat:@"%@ was rejected: %@", command, serviceError]]; } else { - NSLog(@"[HouseArrest] %@ did not complete", command); + WMMBridgeLog(@"[HouseArrest] %@ did not complete", command); [failures addObject:[NSString stringWithFormat:@"%@ did not complete", command]]; } functions->AMDServiceConnectionInvalidate(serviceConnection); @@ -1089,6 +1106,14 @@ static BOOL WMMEntryArrayContainsName(NSArray *entries, NSString *ca return NO; } +static NSArray *WMMIconCandidateFileNamesForContentType(NSString *contentType) { + if ([contentType isEqualToString:@"World"]) { + return @[ @"world_icon.jpeg", @"world_icon.jpg", @"world_icon.png" ]; + } + + return @[ @"pack_icon.png", @"pack_icon.jpeg", @"pack_icon.jpg" ]; +} + static NSString * _Nullable WMMReadUTF8TextFile( WMMMobileDeviceFunctions *functions, AFCConnectionRef afcConnection, @@ -1921,6 +1946,87 @@ WMMCopyConnectedDeviceMinecraftMetadataBatch( }; } +NSDictionary * _Nullable +WMMCopyConnectedDeviceMinecraftIconBatch( + NSString *deviceIdentifier, + NSString *bundleIdentifier, + NSString *relativePath, + NSArray *> *items, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(20, @"A bundle identifier is required."); + } + return nil; + } + + WMMMobileDeviceFunctions functions; + if (!WMMLoadFunctions(&functions, error)) { + return nil; + } + + AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error); + if (device == NULL) { + return nil; + } + + if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) { + functions.AMDeviceRelease(device); + return nil; + } + + AMDServiceConnectionRef backingServiceConnection = NULL; + AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( + &functions, + device, + bundleIdentifier, + &backingServiceConnection, + error + ); + if (afcConnection == NULL) { + WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection); + functions.AMDeviceRelease(device); + return nil; + } + + NSString *normalizedRootPath = WMMNormalizedAFCPath(relativePath); + NSMutableArray *> *results = [NSMutableArray array]; + + for (NSDictionary *item in items) { + NSString *contentType = [item[@"contentType"] isKindOfClass:[NSString class]] ? item[@"contentType"] : nil; + NSString *relativeItemPath = [item[@"relativePath"] isKindOfClass:[NSString class]] ? item[@"relativePath"] : nil; + if (contentType.length == 0 || relativeItemPath.length == 0) { + continue; + } + + NSString *itemRemotePath = [normalizedRootPath stringByAppendingPathComponent:relativeItemPath]; + for (NSString *candidateFileName in WMMIconCandidateFileNamesForContentType(contentType)) { + NSString *candidateRemotePath = [itemRemotePath stringByAppendingPathComponent:candidateFileName]; + NSData *data = WMMCopyAFCFileData(&functions, afcConnection, candidateRemotePath, NULL); + if (data == nil) { + continue; + } + + [results addObject:@{ + @"relativePath": relativeItemPath, + @"iconFileName": candidateFileName, + @"data": data + }]; + break; + } + } + + WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection); + functions.AMDeviceRelease(device); + + return @{ + @"bundleIdentifier": bundleIdentifier, + @"path": normalizedRootPath, + @"items": results + }; +} + NSData * _Nullable WMMCopyConnectedDeviceAppFileData( NSString *deviceIdentifier, @@ -2039,6 +2145,82 @@ WMMCopyConnectedDeviceAppPathMetrics( }; } +NSDictionary * _Nullable +WMMCopyConnectedDeviceAppPathMetricsBatch( + NSString *deviceIdentifier, + NSString *bundleIdentifier, + NSArray *relativePaths, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(21, @"A bundle identifier is required."); + } + return nil; + } + + WMMMobileDeviceFunctions functions; + if (!WMMLoadFunctions(&functions, error)) { + return nil; + } + + AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error); + if (device == NULL) { + return nil; + } + + if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) { + functions.AMDeviceRelease(device); + return nil; + } + + AMDServiceConnectionRef backingServiceConnection = NULL; + AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( + &functions, + device, + bundleIdentifier, + &backingServiceConnection, + error + ); + if (afcConnection == NULL) { + WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection); + functions.AMDeviceRelease(device); + return nil; + } + + NSMutableArray *> *results = [NSMutableArray array]; + for (NSString *relativePath in relativePaths) { + if (![relativePath isKindOfClass:[NSString class]] || relativePath.length == 0) { + continue; + } + + NSString *normalizedPath = WMMNormalizedAFCPath(relativePath); + NSDictionary *metrics = WMMCopyAFCTreeMetrics( + &functions, + afcConnection, + normalizedPath, + NULL + ); + + NSMutableDictionary *result = [@{ + @"relativePath": relativePath + } mutableCopy]; + if (metrics != nil) { + result[@"sizeBytes"] = metrics[@"sizeBytes"] ?: @0; + result[@"modifiedDate"] = metrics[@"modifiedDate"] ?: [NSNull null]; + } + [results addObject:result]; + } + + WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection); + functions.AMDeviceRelease(device); + + return @{ + @"bundleIdentifier": bundleIdentifier, + @"items": results + }; +} + NSDictionary * _Nullable WMMCopyConnectedDeviceApplicationDetails( NSString *deviceIdentifier, diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift index 9a44bdb..681284b 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -178,6 +178,70 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { return previewItem } + nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + guard case .connectedDevice(_, let container) = source.origin else { + var previewItems: [MinecraftContentItem] = [] + previewItems.reserveCapacity(items.count) + for item in items { + previewItems.append(await loadPreviewAssets(for: item, in: source)) + } + return previewItems + } + + let summaries = items.compactMap { item -> AppleMobileMinecraftLibraryItemSummary? in + guard item.hasKnownIcon, let relativePath = relativeItemPath(for: item, in: source) else { + return nil + } + + return AppleMobileMinecraftLibraryItemSummary( + contentType: item.contentType.rawValue, + collectionFolderName: item.collectionRootURL.lastPathComponent, + relativePath: relativePath, + folderName: item.folderName, + displayName: item.displayName, + hasIcon: true + ) + } + + let iconsByRelativePath: [String: URL] + if summaries.isEmpty { + iconsByRelativePath = [:] + } else if let iconSummaries = try? await AppleMobileDeviceAccess.minecraftIconBatch( + deviceIdentifier: container.deviceUDID, + bundleIdentifier: container.appID, + relativePath: container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + items: summaries + ) { + var resolvedIcons: [String: URL] = [:] + resolvedIcons.reserveCapacity(iconSummaries.count) + + for iconSummary in iconSummaries { + let pathExtension = NSString(string: iconSummary.iconFileName).pathExtension + let cachedURL = await ImageCacheStore.shared.cachedImageURL( + forRemoteData: iconSummary.data, + cacheKey: "\(container.deviceUDID)::\(container.appID)::\(iconSummary.relativePath)::\(iconSummary.iconFileName)", + pathExtension: pathExtension + ) + if let cachedURL { + resolvedIcons[iconSummary.relativePath] = cachedURL + } + } + iconsByRelativePath = resolvedIcons + } else { + iconsByRelativePath = [:] + } + + return items.map { item in + var previewItem = item + if let relativePath = relativeItemPath(for: item, in: source), + let cachedURL = iconsByRelativePath[relativePath] { + previewItem.iconURL = cachedURL + } + previewItem.previewLoaded = true + return previewItem + } + } + nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem { var sizedItem = item guard case .connectedDevice(_, let container) = source.origin else { @@ -201,6 +265,49 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { return sizedItem } + nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + guard case .connectedDevice(_, let container) = source.origin else { + var sizedItems: [MinecraftContentItem] = [] + sizedItems.reserveCapacity(items.count) + for item in items { + sizedItems.append(await loadSize(for: item, in: source)) + } + return sizedItems + } + + let relativePathsByItemID = Dictionary(uniqueKeysWithValues: items.compactMap { item in + remoteItemPath(for: item, in: source).map { (item.id, $0) } + }) + + let metricsByRelativePath: [String: AppleMobileDevicePathMetrics] + if relativePathsByItemID.isEmpty { + metricsByRelativePath = [:] + } else if let metricSummaries = try? await AppleMobileDeviceAccess.pathMetricsBatch( + deviceIdentifier: container.deviceUDID, + bundleIdentifier: container.appID, + relativePaths: Array(relativePathsByItemID.values) + ) { + metricsByRelativePath = Dictionary( + uniqueKeysWithValues: metricSummaries.map { ($0.relativePath, $0.metrics) } + ) + } else { + metricsByRelativePath = [:] + } + + return items.map { item in + var sizedItem = item + if let relativePath = relativePathsByItemID[item.id], + let metrics = metricsByRelativePath[relativePath] { + sizedItem.sizeBytes = metrics.sizeBytes + if sizedItem.modifiedDate == nil { + sizedItem.modifiedDate = metrics.modifiedDate + } + } + sizedItem.sizeLoaded = true + return sizedItem + } + } + nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] { guard case .connectedDevice(_, let container) = source.origin else { return [] @@ -389,7 +496,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { return nil } - let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "") + let relativeItemPath = relativeItemPath(for: item, in: source) ?? "" guard !relativeItemPath.isEmpty else { return nil } @@ -406,6 +513,11 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { return basePath } + nonisolated private func relativeItemPath(for item: MinecraftContentItem, in source: MinecraftSource) -> String? { + let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "") + return relativeItemPath.isEmpty ? nil : relativeItemPath + } + nonisolated private func loadRemoteIcon( for item: MinecraftContentItem, source: MinecraftSource, diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index e93b5c3..aef1463 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -17,7 +17,9 @@ protocol SourceAccessMethod: Sendable { ) async throws -> [MinecraftContentItem] nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem + nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem + nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async @@ -61,11 +63,33 @@ extension SourceAccessMethod { return item } + nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + var previewItems: [MinecraftContentItem] = [] + previewItems.reserveCapacity(items.count) + + for item in items { + previewItems.append(await loadPreviewAssets(for: item, in: source)) + } + + return previewItems + } + nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem { _ = source return item } + nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + var sizedItems: [MinecraftContentItem] = [] + sizedItems.reserveCapacity(items.count) + + for item in items { + sizedItems.append(await loadSize(for: item, in: source)) + } + + return sizedItems + } + nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] { _ = source _ = item @@ -144,10 +168,18 @@ struct SourceAccessCoordinator: SourceAccessMethod { return await accessMethod(for: source).loadPreviewAssets(for: item, in: source) } + nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + return await accessMethod(for: source).loadPreviewAssets(for: items, in: source) + } + nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem { return await accessMethod(for: source).loadSize(for: item, in: source) } + nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] { + return await accessMethod(for: source).loadSizeAssets(for: items, in: source) + } + nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] { return try await accessMethod(for: source).listItemContents(for: item, in: source) }