devce status and scan progress, library restoration
This commit is contained in:
parent
ee621d7eb2
commit
42366c1713
@ -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
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -60,6 +60,15 @@ WMMCopyConnectedDeviceMinecraftMetadataBatch(
|
||||
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
|
||||
WMMCopyConnectedDeviceAppFileData(
|
||||
NSString *deviceIdentifier,
|
||||
@ -76,6 +85,14 @@ WMMCopyConnectedDeviceAppPathMetrics(
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyConnectedDeviceAppPathMetricsBatch(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSArray<NSString *> *relativePaths,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT BOOL
|
||||
WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
NSString *deviceIdentifier,
|
||||
|
||||
@ -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 : @"<nil>";
|
||||
}
|
||||
|
||||
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<NSString *> *entries, NSString *ca
|
||||
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(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
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
|
||||
WMMCopyConnectedDeviceAppFileData(
|
||||
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
|
||||
WMMCopyConnectedDeviceApplicationDetails(
|
||||
NSString *deviceIdentifier,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user