devce status and scan progress, library restoration

This commit is contained in:
John Burwell 2026-05-28 14:00:34 -05:00
parent ee621d7eb2
commit 42366c1713
9 changed files with 1668 additions and 234 deletions

View File

@ -87,6 +87,21 @@ struct ItemDetailColumnView: View {
} }
private struct SourceDetailView: 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 let source: MinecraftSource
var body: some View { var body: some View {
@ -101,6 +116,10 @@ private struct SourceDetailView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if showsStatusSection {
sourceStatusSection
}
sourceSection(title: "Overview", rows: overviewRows) sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Contents", rows: contentRows) sourceSection(title: "Contents", rows: contentRows)
sourceSection(title: "Location", rows: locationRows) 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)] { private var overviewRows: [(String, String)] {
var rows: [(String, String)] = [ var rows: [(String, String)] = [
("Type", sourceTypeLabel), ("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 { private func itemCount(for type: MinecraftContentType) -> Int {
source.items.filter { $0.contentType == type }.count source.items.filter { $0.contentType == type }.count
} }

View File

@ -7,6 +7,15 @@
import Foundation import Foundation
enum SourceScanPhase {
case discovering
case metadata
case previews
case sizing
case completed
case idle
}
struct MinecraftSource: Identifiable, Hashable, Sendable { struct MinecraftSource: Identifiable, Hashable, Sendable {
let id: URL let id: URL
let folderURL: URL let folderURL: URL
@ -29,6 +38,12 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var scanProgress: Double? var scanProgress: Double?
var indexedItemCount: Int var indexedItemCount: Int
var indexedDetailCount: 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? var lastScanDate: Date?
nonisolated init( nonisolated init(
@ -67,6 +82,12 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
self.scanProgress = nil self.scanProgress = nil
self.indexedItemCount = 0 self.indexedItemCount = 0
self.indexedDetailCount = 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 self.lastScanDate = nil
} }
@ -74,10 +95,147 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
displayItems.count 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] { var items: [MinecraftContentItem] {
displayItems 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? { func rawItem(withID itemID: URL) -> MinecraftContentItem? {
rawItems.first(where: { $0.id == itemID }) rawItems.first(where: { $0.id == itemID })
} }

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@ struct SourcesSidebarView: View {
connectedDeviceSectionRows(for: entry) connectedDeviceSectionRows(for: entry)
} }
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Connected Devices") SidebarSourcesSectionHeaderView(title: "Available Devices")
} }
} }
} }
@ -153,7 +153,6 @@ private struct SourceHeaderRow: View {
let source: MinecraftSource let source: MinecraftSource
let isSelected: Bool let isSelected: Bool
let onSelect: () -> Void let onSelect: () -> Void
@State private var isPresentingStatusPopover = false
@State private var isHovering = false @State private var isHovering = false
var body: some View { var body: some View {
@ -172,19 +171,13 @@ private struct SourceHeaderRow: View {
SourceConnectionBadge(connection: connection) SourceConnectionBadge(connection: connection)
} }
if showsStatusButton { if let availabilityBadgeText {
Button { SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
isPresentingStatusPopover = true }
} label: {
if showsStatusIndicator {
statusIndicator statusIndicator
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(scanStatusHelpText)
.popover(isPresented: $isPresentingStatusPopover, arrowEdge: .top) {
SourceStatusPopover(source: source)
}
} }
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
@ -203,18 +196,6 @@ private struct SourceHeaderRow: View {
return device.connection 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 { private var headerSymbolName: String {
switch source.origin { switch source.origin {
case .localFolder: case .localFolder:
@ -225,7 +206,30 @@ private struct SourceHeaderRow: View {
} }
private var titleColor: Color { 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 { private var backgroundStyle: AnyShapeStyle {
@ -249,6 +253,12 @@ private struct SourceHeaderRow: View {
ProgressView() ProgressView()
.controlSize(.small) .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 { } else if source.scanError != nil {
Image(systemName: "exclamationmark.circle") Image(systemName: "exclamationmark.circle")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -258,8 +268,8 @@ private struct SourceHeaderRow: View {
} }
} }
private var showsStatusButton: Bool { private var showsStatusIndicator: Bool {
source.isScanning || source.scanError != nil source.isScanning || source.scanError != nil || source.availability != .available
} }
} }
@ -296,92 +306,21 @@ private struct SourceConnectionBadge: View {
} }
} }
private struct SourceStatusPopover: View { private struct SourceAvailabilityBadge: View {
let source: MinecraftSource let text: String
let emphasis: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { Text(text)
HStack(alignment: .center, spacing: 8) { .font(.caption.weight(.semibold))
if !source.isScanning, source.scanError != nil { .foregroundStyle(emphasis ? Color.appAccent : .secondary)
Image(systemName: "exclamationmark.triangle.fill") .padding(.horizontal, 7)
.foregroundStyle(.orange) .padding(.vertical, 4)
} else if !source.isScanning { .background(backgroundColor, in: Capsule())
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
} }
Text(titleText) private var backgroundColor: Color {
.font(.headline) emphasis ? Color.appAccent.opacity(0.14) : .secondary.opacity(0.12)
}
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)
}
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"
} }
} }
@ -473,8 +412,8 @@ private struct ConnectedDeviceRow: View {
switch entry.device.trustState { switch entry.device.trustState {
case .trusted: case .trusted:
if entry.hasMinecraftContainer, let container = entry.minecraftContainer { if entry.hasMinecraftContainer {
return "Minecraft found in \(container.appName)" return "Ready to add Minecraft library"
} }
return "No Minecraft source found" return "No Minecraft source found"

View File

@ -49,11 +49,22 @@ struct AppleMobileMinecraftItemMetadataSummary: Sendable {
let packReferences: [AppleMobilePackReferenceSummary] let packReferences: [AppleMobilePackReferenceSummary]
} }
struct AppleMobileMinecraftIconSummary: Sendable {
let relativePath: String
let iconFileName: String
let data: Data
}
struct AppleMobileDevicePathMetrics: Sendable { struct AppleMobileDevicePathMetrics: Sendable {
let sizeBytes: Int64? let sizeBytes: Int64?
let modifiedDate: Date? let modifiedDate: Date?
} }
struct AppleMobileDevicePathMetricsSummary: Sendable {
let relativePath: String
let metrics: AppleMobileDevicePathMetrics
}
actor AppleMobileDeviceOperationLimiter { actor AppleMobileDeviceOperationLimiter {
static let shared = 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( static func pathMetrics(
deviceIdentifier: String, deviceIdentifier: String,
bundleIdentifier: 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 { nonisolated private static func flexibleBool(from value: Any?) -> Bool {
switch value { switch value {
case let value as Bool: case let value as Bool:

View File

@ -60,6 +60,15 @@ WMMCopyConnectedDeviceMinecraftMetadataBatch(
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceMinecraftIconBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSArray<NSDictionary<NSString *, id> *> *items,
NSError **error
);
FOUNDATION_EXPORT NSData * _Nullable FOUNDATION_EXPORT NSData * _Nullable
WMMCopyConnectedDeviceAppFileData( WMMCopyConnectedDeviceAppFileData(
NSString *deviceIdentifier, NSString *deviceIdentifier,
@ -76,6 +85,14 @@ WMMCopyConnectedDeviceAppPathMetrics(
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceAppPathMetricsBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSArray<NSString *> *relativePaths,
NSError **error
);
FOUNDATION_EXPORT BOOL FOUNDATION_EXPORT BOOL
WMMCopyConnectedDeviceAppSubtreeToLocalDirectory( WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
NSString *deviceIdentifier, NSString *deviceIdentifier,

View File

@ -13,6 +13,23 @@
NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain"; 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 *AMDeviceRef;
typedef struct am_device_notification *AMDeviceNotificationRef; typedef struct am_device_notification *AMDeviceNotificationRef;
typedef struct amd_service_connection *AMDServiceConnectionRef; typedef struct amd_service_connection *AMDServiceConnectionRef;
@ -455,7 +472,7 @@ static void WMMLogDeviceTransportDiagnostics(
values[key] = value.length > 0 ? value : @"<nil>"; values[key] = value.length > 0 ? value : @"<nil>";
} }
NSLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values); WMMBridgeLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values);
} }
static BOOL WMMConnectAndValidateDevice( static BOOL WMMConnectAndValidateDevice(
@ -576,7 +593,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
*backingServiceConnection = NULL; *backingServiceConnection = NULL;
} }
NSLog(@"[HouseArrest] Trying AMDeviceCreateHouseArrestService for %@", bundleIdentifier); WMMBridgeLog(@"[HouseArrest] Trying AMDeviceCreateHouseArrestService for %@", bundleIdentifier);
AFCConnectionRef directConnection = NULL; AFCConnectionRef directConnection = NULL;
int directStatus = functions->AMDeviceCreateHouseArrestService( int directStatus = functions->AMDeviceCreateHouseArrestService(
device, device,
@ -584,7 +601,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
NULL, NULL,
&directConnection &directConnection
); );
NSLog(@"[HouseArrest] AMDeviceCreateHouseArrestService returned %d connection=%p", directStatus, directConnection); WMMBridgeLog(@"[HouseArrest] AMDeviceCreateHouseArrestService returned %d connection=%p", directStatus, directConnection);
if (directStatus == 0 && directConnection != NULL) { if (directStatus == 0 && directConnection != NULL) {
return directConnection; return directConnection;
} }
@ -594,7 +611,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
[failures addObject:[NSString stringWithFormat:@"AMDeviceCreateHouseArrestService returned %d", directStatus]]; [failures addObject:[NSString stringWithFormat:@"AMDeviceCreateHouseArrestService returned %d", directStatus]];
for (NSString *command in commands) { for (NSString *command in commands) {
NSLog(@"[HouseArrest] Starting %@ for %@", command, bundleIdentifier); WMMBridgeLog(@"[HouseArrest] Starting %@ for %@", command, bundleIdentifier);
AMDServiceConnectionRef serviceConnection = NULL; AMDServiceConnectionRef serviceConnection = NULL;
int startStatus = functions->AMDeviceSecureStartService( int startStatus = functions->AMDeviceSecureStartService(
device, device,
@ -603,14 +620,14 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
&serviceConnection &serviceConnection
); );
if (startStatus != 0 || serviceConnection == NULL) { 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]]; [failures addObject:[NSString stringWithFormat:@"%@ service start failed (%d)", command, startStatus]];
continue; continue;
} }
int socket = functions->AMDServiceConnectionGetSocket(serviceConnection); int socket = functions->AMDServiceConnectionGetSocket(serviceConnection);
void *secureContext = functions->AMDServiceConnectionGetSecureIOContext(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 = @{ NSDictionary *request = @{
@"Command": command, @"Command": command,
@ -622,7 +639,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
(__bridge CFPropertyListRef)request, (__bridge CFPropertyListRef)request,
100 100
); );
NSLog(@"[HouseArrest] %@ send returned %d", command, sent); WMMBridgeLog(@"[HouseArrest] %@ send returned %d", command, sent);
if (sent != 0) { if (sent != 0) {
[failures addObject:[NSString stringWithFormat:@"%@ request failed to send (%d)", command, sent]]; [failures addObject:[NSString stringWithFormat:@"%@ request failed to send (%d)", command, sent]];
functions->AMDServiceConnectionInvalidate(serviceConnection); functions->AMDServiceConnectionInvalidate(serviceConnection);
@ -635,7 +652,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
&response, &response,
0 0
); );
NSLog(@"[HouseArrest] %@ receive returned %d", command, received); WMMBridgeLog(@"[HouseArrest] %@ receive returned %d", command, received);
if (received != 0 || response == NULL) { if (received != 0 || response == NULL) {
[failures addObject:[NSString stringWithFormat:@"%@ response could not be read (%d)", command, received]]; [failures addObject:[NSString stringWithFormat:@"%@ response could not be read (%d)", command, received]];
functions->AMDServiceConnectionInvalidate(serviceConnection); functions->AMDServiceConnectionInvalidate(serviceConnection);
@ -643,7 +660,7 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
} }
NSDictionary *responseDictionary = CFBridgingRelease(response); NSDictionary *responseDictionary = CFBridgingRelease(response);
NSLog(@"[HouseArrest] %@ response: %@", command, responseDictionary); WMMBridgeLog(@"[HouseArrest] %@ response: %@", command, responseDictionary);
NSString *status = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Status"] : nil; NSString *status = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Status"] : nil;
if ([status isKindOfClass:[NSString class]] && [status isEqualToString:@"Complete"]) { if ([status isKindOfClass:[NSString class]] && [status isEqualToString:@"Complete"]) {
AFCConnectionRef afcConnection = WMMCreateAFCConnectionFromServiceConnection(functions, serviceConnection); AFCConnectionRef afcConnection = WMMCreateAFCConnectionFromServiceConnection(functions, serviceConnection);
@ -651,22 +668,22 @@ static AFCConnectionRef _Nullable WMMCreateVendAFCConnection(
if (backingServiceConnection != NULL) { if (backingServiceConnection != NULL) {
*backingServiceConnection = serviceConnection; *backingServiceConnection = serviceConnection;
} }
NSLog(@"[HouseArrest] %@ completed and AFC initialized", command); WMMBridgeLog(@"[HouseArrest] %@ completed and AFC initialized", command);
return afcConnection; return afcConnection;
} }
functions->AMDServiceConnectionInvalidate(serviceConnection); 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]]; [failures addObject:[NSString stringWithFormat:@"%@ succeeded but AFC initialization failed", command]];
break; break;
} }
NSString *serviceError = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Error"] : nil; NSString *serviceError = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Error"] : nil;
if ([serviceError isKindOfClass:[NSString class]] && serviceError.length > 0) { 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]]; [failures addObject:[NSString stringWithFormat:@"%@ was rejected: %@", command, serviceError]];
} else { } else {
NSLog(@"[HouseArrest] %@ did not complete", command); WMMBridgeLog(@"[HouseArrest] %@ did not complete", command);
[failures addObject:[NSString stringWithFormat:@"%@ did not complete", command]]; [failures addObject:[NSString stringWithFormat:@"%@ did not complete", command]];
} }
functions->AMDServiceConnectionInvalidate(serviceConnection); functions->AMDServiceConnectionInvalidate(serviceConnection);
@ -1089,6 +1106,14 @@ static BOOL WMMEntryArrayContainsName(NSArray<NSString *> *entries, NSString *ca
return NO; return NO;
} }
static NSArray<NSString *> *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( static NSString * _Nullable WMMReadUTF8TextFile(
WMMMobileDeviceFunctions *functions, WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection, AFCConnectionRef afcConnection,
@ -1921,6 +1946,87 @@ WMMCopyConnectedDeviceMinecraftMetadataBatch(
}; };
} }
NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceMinecraftIconBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSArray<NSDictionary<NSString *, id> *> *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<NSDictionary<NSString *, id> *> *results = [NSMutableArray array];
for (NSDictionary<NSString *, id> *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 NSData * _Nullable
WMMCopyConnectedDeviceAppFileData( WMMCopyConnectedDeviceAppFileData(
NSString *deviceIdentifier, NSString *deviceIdentifier,
@ -2039,6 +2145,82 @@ WMMCopyConnectedDeviceAppPathMetrics(
}; };
} }
NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceAppPathMetricsBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSArray<NSString *> *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<NSDictionary<NSString *, id> *> *results = [NSMutableArray array];
for (NSString *relativePath in relativePaths) {
if (![relativePath isKindOfClass:[NSString class]] || relativePath.length == 0) {
continue;
}
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
NSDictionary<NSString *, id> *metrics = WMMCopyAFCTreeMetrics(
&functions,
afcConnection,
normalizedPath,
NULL
);
NSMutableDictionary<NSString *, id> *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<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceApplicationDetails( WMMCopyConnectedDeviceApplicationDetails(
NSString *deviceIdentifier, NSString *deviceIdentifier,

View File

@ -178,6 +178,70 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
return previewItem 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
var sizedItem = item var sizedItem = item
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice(_, let container) = source.origin else {
@ -201,6 +265,49 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
return sizedItem 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] { nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice(_, let container) = source.origin else {
return [] return []
@ -389,7 +496,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
return nil return nil
} }
let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "") let relativeItemPath = relativeItemPath(for: item, in: source) ?? ""
guard !relativeItemPath.isEmpty else { guard !relativeItemPath.isEmpty else {
return nil return nil
} }
@ -406,6 +513,11 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
return basePath 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( nonisolated private func loadRemoteIcon(
for item: MinecraftContentItem, for item: MinecraftContentItem,
source: MinecraftSource, source: MinecraftSource,

View File

@ -17,7 +17,9 @@ protocol SourceAccessMethod: Sendable {
) async throws -> [MinecraftContentItem] ) async throws -> [MinecraftContentItem]
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> 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 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 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 listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry]
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
@ -61,11 +63,33 @@ extension SourceAccessMethod {
return item 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source _ = source
return item 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] { nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
_ = source _ = source
_ = item _ = item
@ -144,10 +168,18 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).loadPreviewAssets(for: item, in: source) 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
return await accessMethod(for: source).loadSize(for: item, in: source) 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] { nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
return try await accessMethod(for: source).listItemContents(for: item, in: source) return try await accessMethod(for: source).listItemContents(for: item, in: source)
} }