Refactor, support lightweight device scanning
This commit is contained in:
parent
aca5baa155
commit
ab6661d66b
@ -19,6 +19,7 @@ struct ContentView: View {
|
|||||||
@State private var isPerformingItemAction = false
|
@State private var isPerformingItemAction = false
|
||||||
@State private var isShowingDeviceSourceSheet = false
|
@State private var isShowingDeviceSourceSheet = false
|
||||||
@State private var sortMode: ItemSortMode = .name
|
@State private var sortMode: ItemSortMode = .name
|
||||||
|
@State private var directoryPreviewContents: [DirectoryPreviewEntry] = []
|
||||||
|
|
||||||
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
|
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
|
||||||
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
||||||
@ -32,7 +33,8 @@ struct ContentView: View {
|
|||||||
wrappedValue: SourceLibrary(
|
wrappedValue: SourceLibrary(
|
||||||
sourceAccessMethod: SourceAccessCoordinator(
|
sourceAccessMethod: SourceAccessCoordinator(
|
||||||
connectedDeviceAccess: connectedDeviceAccess
|
connectedDeviceAccess: connectedDeviceAccess
|
||||||
)
|
),
|
||||||
|
connectedDeviceAccessMethod: connectedDeviceAccess
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -40,11 +42,13 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
SourcesSidebarView(
|
SourcesSidebarView(
|
||||||
sources: library.sources,
|
localSources: library.localSources,
|
||||||
|
connectedDevices: library.connectedDevices,
|
||||||
selection: $selectedSidebarSelection,
|
selection: $selectedSidebarSelection,
|
||||||
footerState: library.sidebarFooterState,
|
footerState: library.sidebarFooterState,
|
||||||
addSourceAction: pickFolder,
|
addSourceAction: pickFolder,
|
||||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||||
|
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
||||||
rescanSourceAction: { source in
|
rescanSourceAction: { source in
|
||||||
selectedSidebarSelection = .allContent(sourceID: source.id)
|
selectedSidebarSelection = .allContent(sourceID: source.id)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
@ -54,12 +58,19 @@ struct ContentView: View {
|
|||||||
removeSource(source.id)
|
removeSource(source.id)
|
||||||
},
|
},
|
||||||
revealFooterURLAction: revealURLInFinder(_:),
|
revealFooterURLAction: revealURLInFinder(_:),
|
||||||
filters: sidebarFilters(for:)
|
filters: sidebarFilters(for:),
|
||||||
|
matchedSource: { entry in
|
||||||
|
guard let sourceID = entry.matchedSourceID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return library.source(withID: sourceID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||||
} content: {
|
} content: {
|
||||||
ItemListColumnView(
|
ItemListColumnView(
|
||||||
isEmpty: library.sources.isEmpty,
|
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||||
isDropTargeted: $isDropTargeted,
|
isDropTargeted: $isDropTargeted,
|
||||||
selectedItemID: $selectedItemID,
|
selectedItemID: $selectedItemID,
|
||||||
searchText: $searchText,
|
searchText: $searchText,
|
||||||
@ -87,9 +98,9 @@ struct ContentView: View {
|
|||||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||||
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
||||||
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
||||||
contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [],
|
contents: directoryPreviewContents,
|
||||||
directoryPreviewLimit: directoryPreviewLimit,
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
isEmpty: library.sources.isEmpty,
|
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||||
isPerformingItemAction: isPerformingItemAction,
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
||||||
exportAction: {
|
exportAction: {
|
||||||
@ -126,7 +137,7 @@ struct ContentView: View {
|
|||||||
deviceDiscoveryService: connectedDeviceAccess,
|
deviceDiscoveryService: connectedDeviceAccess,
|
||||||
sourceFactory: deviceSourceFactory,
|
sourceFactory: deviceSourceFactory,
|
||||||
onAddSource: { source in
|
onAddSource: { source in
|
||||||
let sourceID = library.addSource(source, shouldPersist: false, shouldScan: true)
|
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
isShowingDeviceSourceSheet = false
|
isShowingDeviceSourceSheet = false
|
||||||
@ -141,8 +152,14 @@ struct ContentView: View {
|
|||||||
|
|
||||||
self.selectedItemID = nil
|
self.selectedItemID = nil
|
||||||
}
|
}
|
||||||
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
.onChange(of: library.sources.map(\.id)) { _, _ in
|
||||||
syncSelection(with: sourceIDs)
|
syncSelection(with: library.visibleSources.map(\.id))
|
||||||
|
}
|
||||||
|
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
|
||||||
|
syncSelection(with: library.visibleSources.map(\.id))
|
||||||
|
}
|
||||||
|
.task(id: currentSelectedItem?.id) {
|
||||||
|
await refreshDirectoryPreviewContents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +193,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var currentSource: MinecraftSource? {
|
private var currentSource: MinecraftSource? {
|
||||||
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
||||||
return library.sources.first
|
return library.visibleSources.first
|
||||||
}
|
}
|
||||||
|
|
||||||
return library.source(withID: sourceID)
|
return library.source(withID: sourceID)
|
||||||
@ -187,7 +204,7 @@ struct ContentView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return library.sources
|
return library.visibleSources
|
||||||
.flatMap(\.items)
|
.flatMap(\.items)
|
||||||
.first(where: { $0.id == selectedItemID })
|
.first(where: { $0.id == selectedItemID })
|
||||||
}
|
}
|
||||||
@ -503,31 +520,22 @@ struct ContentView: View {
|
|||||||
return logicalPack.isSuspicious
|
return logicalPack.isSuspicious
|
||||||
}
|
}
|
||||||
|
|
||||||
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
private func refreshDirectoryPreviewContents() async {
|
||||||
let fileManager = FileManager.default
|
guard let item = currentSelectedItem, let source = currentSource else {
|
||||||
|
await MainActor.run {
|
||||||
guard let urls = try? fileManager.contentsOfDirectory(
|
directoryPreviewContents = []
|
||||||
at: item.folderURL,
|
}
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
return
|
||||||
options: [.skipsHiddenFiles]
|
|
||||||
) else {
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls
|
let contents = (try? await library.listContents(for: item, in: source)) ?? []
|
||||||
.map { url in
|
guard !Task.isCancelled else {
|
||||||
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
return
|
||||||
return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory)
|
}
|
||||||
}
|
|
||||||
.sorted { lhs, rhs in
|
|
||||||
if lhs.isDirectory != rhs.isDirectory {
|
|
||||||
return lhs.isDirectory && !rhs.isDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
await MainActor.run {
|
||||||
}
|
directoryPreviewContents = Array(contents.prefix(directoryPreviewLimit))
|
||||||
.prefix(directoryPreviewLimit)
|
}
|
||||||
.map { $0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickFolder() {
|
private func pickFolder() {
|
||||||
@ -582,7 +590,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func removeSource(_ sourceID: URL) {
|
private func removeSource(_ sourceID: URL) {
|
||||||
let fallbackSourceID = library.sources.first(where: { $0.id != sourceID })?.id
|
let fallbackSourceID = library.visibleSources.first(where: { $0.id != sourceID })?.id
|
||||||
library.removeSource(withID: sourceID)
|
library.removeSource(withID: sourceID)
|
||||||
|
|
||||||
if selectedSidebarSelection?.sourceID == sourceID {
|
if selectedSidebarSelection?.sourceID == sourceID {
|
||||||
@ -594,6 +602,17 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addConnectedDeviceSource(from entry: ConnectedDeviceSidebarEntry) {
|
||||||
|
guard let container = entry.minecraftContainer else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = deviceSourceFactory.makeSource(device: entry.device, container: container)
|
||||||
|
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
|
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||||
|
selectedItemID = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func syncSelection(with sourceIDs: [URL]) {
|
private func syncSelection(with sourceIDs: [URL]) {
|
||||||
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
||||||
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
|
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
|
||||||
@ -602,7 +621,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let selectedItemID {
|
if let selectedItemID {
|
||||||
let itemStillExists = library.sources
|
let itemStillExists = library.visibleSources
|
||||||
.flatMap(\.items)
|
.flatMap(\.items)
|
||||||
.contains(where: { $0.id == selectedItemID })
|
.contains(where: { $0.id == selectedItemID })
|
||||||
|
|
||||||
@ -636,7 +655,11 @@ struct ContentView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let finalURL = try await Task.detached(priority: .userInitiated) {
|
let finalURL = try await Task.detached(priority: .userInitiated) {
|
||||||
try ContentPackageExporter.createArchiveFile(for: item, source: source, destinationURL: destinationURL)
|
try await ContentPackageExporter.createArchiveFile(
|
||||||
|
for: item,
|
||||||
|
source: source,
|
||||||
|
destinationURL: destinationURL
|
||||||
|
)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@ -668,7 +691,10 @@ struct ContentView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let shareURL = try await Task.detached(priority: .userInitiated) {
|
let shareURL = try await Task.detached(priority: .userInitiated) {
|
||||||
try ContentPackageExporter.createArchiveFile(for: item, source: source)
|
try await ContentPackageExporter.createArchiveFile(
|
||||||
|
for: item,
|
||||||
|
source: source
|
||||||
|
)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@ -703,7 +729,42 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func revealInFinder(_ item: MinecraftContentItem) {
|
private func revealInFinder(_ item: MinecraftContentItem) {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
guard let source = currentSource else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.origin.kind == .localFolder {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isPerformingItemAction else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPerformingItemAction = true
|
||||||
|
library.setItemActionInProgress("Preparing item for Finder...")
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let revealURL = try await library.materializeItem(item, in: source)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isPerformingItemAction = false
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([revealURL])
|
||||||
|
library.setItemActionSuccess(
|
||||||
|
title: "Prepared for Finder",
|
||||||
|
subtitle: item.displayName,
|
||||||
|
revealURL: revealURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isPerformingItemAction = false
|
||||||
|
library.setItemActionFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func revealURLInFinder(_ url: URL) {
|
private func revealURLInFinder(_ url: URL) {
|
||||||
|
|||||||
@ -612,6 +612,10 @@ struct ItemDetailView: View {
|
|||||||
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if item.sizeLoaded {
|
||||||
|
return "Unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,8 @@ private struct ContentRowView: View {
|
|||||||
let sizeText: String
|
let sizeText: String
|
||||||
if let sizeBytes = item.sizeBytes {
|
if let sizeBytes = item.sizeBytes {
|
||||||
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||||
|
} else if item.sizeLoaded {
|
||||||
|
sizeText = "Size unavailable"
|
||||||
} else if item.metadataLoaded {
|
} else if item.metadataLoaded {
|
||||||
sizeText = "Calculating size..."
|
sizeText = "Calculating size..."
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -11,6 +11,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
let id: URL
|
let id: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
var origin: MinecraftSourceOrigin
|
var origin: MinecraftSourceOrigin
|
||||||
|
var accessDescriptor: SourceAccessDescriptor
|
||||||
|
var availability: SourceAvailability
|
||||||
var bookmarkData: Data?
|
var bookmarkData: Data?
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var displayItems: [MinecraftContentItem]
|
var displayItems: [MinecraftContentItem]
|
||||||
@ -31,12 +33,22 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
sourceID: URL? = nil,
|
sourceID: URL? = nil,
|
||||||
folderURL: URL,
|
folderURL: URL,
|
||||||
bookmarkData: Data? = nil,
|
bookmarkData: Data? = nil,
|
||||||
origin: MinecraftSourceOrigin? = nil
|
origin: MinecraftSourceOrigin? = nil,
|
||||||
|
accessDescriptor: SourceAccessDescriptor? = nil,
|
||||||
|
availability: SourceAvailability = .unknown
|
||||||
) {
|
) {
|
||||||
let normalizedFolderURL = normalizedSourceURL(folderURL)
|
let normalizedFolderURL = normalizedSourceURL(folderURL)
|
||||||
|
let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||||
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
||||||
self.folderURL = normalizedFolderURL
|
self.folderURL = normalizedFolderURL
|
||||||
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
self.origin = resolvedOrigin
|
||||||
|
self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier,
|
||||||
|
kind: resolvedOrigin.kind,
|
||||||
|
capabilities: resolvedOrigin.defaultCapabilities,
|
||||||
|
refreshStrategy: resolvedOrigin.defaultRefreshStrategy
|
||||||
|
)
|
||||||
|
self.availability = availability
|
||||||
self.bookmarkData = bookmarkData
|
self.bookmarkData = bookmarkData
|
||||||
self.displayName = normalizedFolderURL.lastPathComponent
|
self.displayName = normalizedFolderURL.lastPathComponent
|
||||||
self.displayItems = []
|
self.displayItems = []
|
||||||
@ -108,6 +120,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
|||||||
.uniqued(by: \.id)
|
.uniqued(by: \.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sourceRecord: SourceRecord {
|
||||||
|
SourceRecord(
|
||||||
|
id: id,
|
||||||
|
displayName: displayName,
|
||||||
|
rootURL: folderURL,
|
||||||
|
origin: origin,
|
||||||
|
accessDescriptor: accessDescriptor,
|
||||||
|
availability: availability,
|
||||||
|
lastRefreshDate: lastScanDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool {
|
private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool {
|
||||||
switch item.contentType {
|
switch item.contentType {
|
||||||
case .world, .behaviorPack, .resourcePack:
|
case .world, .behaviorPack, .resourcePack:
|
||||||
|
|||||||
@ -51,7 +51,16 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
|||||||
case localFolder(bookmarkData: Data?)
|
case localFolder(bookmarkData: Data?)
|
||||||
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
|
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
|
||||||
|
|
||||||
var kind: MinecraftSourceKind {
|
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
|
||||||
|
switch self {
|
||||||
|
case .localFolder:
|
||||||
|
return LocalFolderSourceAccess().accessorIdentifier
|
||||||
|
case .connectedDevice:
|
||||||
|
return AppleMobileDeviceSourceAccess().accessorIdentifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var kind: MinecraftSourceKind {
|
||||||
switch self {
|
switch self {
|
||||||
case .localFolder:
|
case .localFolder:
|
||||||
return .localFolder
|
return .localFolder
|
||||||
@ -59,22 +68,27 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
|||||||
return .connectedDevice
|
return .connectedDevice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated var defaultCapabilities: SourceCapabilities {
|
||||||
|
switch self {
|
||||||
|
case .localFolder:
|
||||||
|
return .localFolder
|
||||||
|
case .connectedDevice:
|
||||||
|
return .connectedDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
|
||||||
|
switch self {
|
||||||
|
case .localFolder:
|
||||||
|
return .eagerFullScan
|
||||||
|
case .connectedDevice:
|
||||||
|
return .staged
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
||||||
case localFolder
|
case localFolder
|
||||||
case connectedDevice
|
case connectedDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PreparedScanRoot: Hashable, Sendable {
|
|
||||||
let sourceID: URL
|
|
||||||
let rootURL: URL
|
|
||||||
let mountPointURL: URL?
|
|
||||||
let cleanupBehavior: CleanupBehavior
|
|
||||||
|
|
||||||
enum CleanupBehavior: Hashable, Sendable {
|
|
||||||
case none
|
|
||||||
case unmount
|
|
||||||
case deleteTemporaryDirectory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
61
World Manager for Minecraft/Models/SourceRecord.swift
Normal file
61
World Manager for Minecraft/Models/SourceRecord.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// SourceRecord.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by OpenAI on 2026-05-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias SourceAccessorIdentifier = String
|
||||||
|
|
||||||
|
enum SourceAvailability: String, Hashable, Sendable, Codable {
|
||||||
|
case unknown
|
||||||
|
case available
|
||||||
|
case disconnected
|
||||||
|
case limited
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SourceRefreshStrategy: String, Hashable, Sendable, Codable {
|
||||||
|
case eagerFullScan
|
||||||
|
case staged
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceCapabilities: Hashable, Sendable, Codable {
|
||||||
|
var supportsDirectFileAccess: Bool
|
||||||
|
var supportsStagedRefresh: Bool
|
||||||
|
var supportsPersistentCaching: Bool
|
||||||
|
var supportsLazyMaterialization: Bool
|
||||||
|
|
||||||
|
nonisolated static let localFolder = SourceCapabilities(
|
||||||
|
supportsDirectFileAccess: true,
|
||||||
|
supportsStagedRefresh: false,
|
||||||
|
supportsPersistentCaching: false,
|
||||||
|
supportsLazyMaterialization: false
|
||||||
|
)
|
||||||
|
|
||||||
|
nonisolated static let connectedDevice = SourceCapabilities(
|
||||||
|
supportsDirectFileAccess: false,
|
||||||
|
supportsStagedRefresh: true,
|
||||||
|
supportsPersistentCaching: true,
|
||||||
|
supportsLazyMaterialization: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceAccessDescriptor: Hashable, Sendable, Codable {
|
||||||
|
var accessorIdentifier: SourceAccessorIdentifier
|
||||||
|
var kind: MinecraftSourceKind
|
||||||
|
var capabilities: SourceCapabilities
|
||||||
|
var refreshStrategy: SourceRefreshStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
|
||||||
|
let id: URL
|
||||||
|
var displayName: String
|
||||||
|
var rootURL: URL
|
||||||
|
var origin: MinecraftSourceOrigin
|
||||||
|
var accessDescriptor: SourceAccessDescriptor
|
||||||
|
var availability: SourceAvailability
|
||||||
|
var lastRefreshDate: Date?
|
||||||
|
}
|
||||||
@ -290,15 +290,18 @@ struct SidebarColumnPreviewContainer: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SourcesSidebarView(
|
SourcesSidebarView(
|
||||||
sources: PreviewFixtures.allSources,
|
localSources: PreviewFixtures.allSources,
|
||||||
|
connectedDevices: [],
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
footerState: PreviewFixtures.sidebarFooter,
|
footerState: PreviewFixtures.sidebarFooter,
|
||||||
addSourceAction: {},
|
addSourceAction: {},
|
||||||
addDeviceSourceAction: {},
|
addDeviceSourceAction: {},
|
||||||
|
addConnectedDeviceAction: { _ in },
|
||||||
rescanSourceAction: { _ in },
|
rescanSourceAction: { _ in },
|
||||||
removeSourceAction: { _ in },
|
removeSourceAction: { _ in },
|
||||||
revealFooterURLAction: { _ in },
|
revealFooterURLAction: { _ in },
|
||||||
filters: PreviewFixtures.sidebarFilters(for:)
|
filters: PreviewFixtures.sidebarFilters(for:),
|
||||||
|
matchedSource: { _ in nil }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ enum ContentPackageExporter {
|
|||||||
for item: MinecraftContentItem,
|
for item: MinecraftContentItem,
|
||||||
source: MinecraftSource? = nil,
|
source: MinecraftSource? = nil,
|
||||||
destinationURL: URL? = nil
|
destinationURL: URL? = nil
|
||||||
) throws -> URL {
|
) async throws -> URL {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let archiveURL: URL
|
let archiveURL: URL
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ enum ContentPackageExporter {
|
|||||||
try fileManager.removeItem(at: archiveURL)
|
try fileManager.removeItem(at: archiveURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
try createArchive(for: item, source: source, at: archiveURL)
|
try await createArchive(for: item, source: source, at: archiveURL)
|
||||||
return archiveURL
|
return archiveURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,9 +61,9 @@ enum ContentPackageExporter {
|
|||||||
for item: MinecraftContentItem,
|
for item: MinecraftContentItem,
|
||||||
source: MinecraftSource?,
|
source: MinecraftSource?,
|
||||||
at archiveURL: URL
|
at archiveURL: URL
|
||||||
) throws {
|
) async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let stagingDirectoryURL = try stagedArchiveContents(for: item, source: source, fileManager: fileManager)
|
let stagingDirectoryURL = try await stagedArchiveContents(for: item, source: source, fileManager: fileManager)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
try? fileManager.removeItem(at: stagingDirectoryURL)
|
try? fileManager.removeItem(at: stagingDirectoryURL)
|
||||||
@ -104,31 +104,40 @@ enum ContentPackageExporter {
|
|||||||
for item: MinecraftContentItem,
|
for item: MinecraftContentItem,
|
||||||
source: MinecraftSource?,
|
source: MinecraftSource?,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) throws -> URL {
|
) async throws -> URL {
|
||||||
let stagingDirectoryURL = fileManager.temporaryDirectory
|
let stagingDirectoryURL = fileManager.temporaryDirectory
|
||||||
.appendingPathComponent("MinecraftArchiveStaging", isDirectory: true)
|
.appendingPathComponent("MinecraftArchiveStaging", isDirectory: true)
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
|
||||||
try fileManager.createDirectory(at: stagingDirectoryURL, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: stagingDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let accessURL = try archiveAccessURL(for: item, source: source)
|
|
||||||
let accessedSecurityScope = accessURL.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if accessedSecurityScope {
|
|
||||||
accessURL.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let contents = try fileManager.contentsOfDirectory(
|
if let source, case .connectedDevice(_, let container) = source.origin {
|
||||||
at: item.folderURL,
|
try await materializeConnectedDeviceItem(
|
||||||
includingPropertiesForKeys: nil,
|
item,
|
||||||
options: [.skipsPackageDescendants]
|
source: source,
|
||||||
)
|
container: container,
|
||||||
|
into: stagingDirectoryURL
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let accessURL = try archiveAccessURL(for: item, source: source)
|
||||||
|
let accessedSecurityScope = accessURL.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessedSecurityScope {
|
||||||
|
accessURL.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for entryURL in contents {
|
let contents = try fileManager.contentsOfDirectory(
|
||||||
let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent)
|
at: item.folderURL,
|
||||||
try fileManager.copyItem(at: entryURL, to: destinationURL)
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsPackageDescendants]
|
||||||
|
)
|
||||||
|
|
||||||
|
for entryURL in contents {
|
||||||
|
let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent)
|
||||||
|
try fileManager.copyItem(at: entryURL, to: destinationURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw ExportError.failedToPrepareArchiveContents(
|
throw ExportError.failedToPrepareArchiveContents(
|
||||||
@ -139,6 +148,40 @@ enum ContentPackageExporter {
|
|||||||
return stagingDirectoryURL
|
return stagingDirectoryURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func materializeConnectedDeviceItem(
|
||||||
|
_ item: MinecraftContentItem,
|
||||||
|
source: MinecraftSource,
|
||||||
|
container: DeviceAppContainer,
|
||||||
|
into destinationURL: URL
|
||||||
|
) async throws {
|
||||||
|
let rootPath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !rootPath.isEmpty else {
|
||||||
|
throw ExportError.failedToPrepareArchiveContents("The connected-device source is missing its Minecraft path.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceRootPath = source.folderURL.path
|
||||||
|
let itemPath = item.folderURL.path
|
||||||
|
let relativeItemPath: String
|
||||||
|
if itemPath.hasPrefix(sourceRootPath + "/") {
|
||||||
|
relativeItemPath = String(itemPath.dropFirst(sourceRootPath.count + 1))
|
||||||
|
} else {
|
||||||
|
relativeItemPath = item.folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteItemPath = relativeItemPath
|
||||||
|
.split(separator: "/")
|
||||||
|
.map(String.init)
|
||||||
|
.reduce(rootPath) { partial, component in
|
||||||
|
NSString(string: partial).appendingPathComponent(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remoteItemPath,
|
||||||
|
destinationDirectoryURL: destinationURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func shareArchiveDirectory(fileManager: FileManager) throws -> URL {
|
nonisolated private static func shareArchiveDirectory(fileManager: FileManager) throws -> URL {
|
||||||
let baseDirectoryURL = try fileManager.url(
|
let baseDirectoryURL = try fileManager.url(
|
||||||
for: .cachesDirectory,
|
for: .cachesDirectory,
|
||||||
|
|||||||
@ -76,6 +76,33 @@ actor ImageCacheStore {
|
|||||||
url.standardizedFileURL.path.hasPrefix(cacheDirectoryPath + "/")
|
url.standardizedFileURL.path.hasPrefix(cacheDirectoryPath + "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cachedImageURL(
|
||||||
|
forRemoteData data: Data,
|
||||||
|
cacheKey: String,
|
||||||
|
pathExtension: String
|
||||||
|
) -> URL? {
|
||||||
|
let normalizedExtension = pathExtension.isEmpty ? "img" : pathExtension
|
||||||
|
let dataDigest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
|
let sourceKey = digest(for: cacheKey)
|
||||||
|
let cachedURL = cacheDirectoryURL
|
||||||
|
.appendingPathComponent("\(sourceKey)-\(dataDigest)", isDirectory: false)
|
||||||
|
.appendingPathExtension(normalizedExtension)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: cachedURL.path) {
|
||||||
|
return cachedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeStaleVariants(forSourceKey: sourceKey, keeping: cachedURL)
|
||||||
|
try data.write(to: cachedURL, options: .atomic)
|
||||||
|
return cachedURL
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func purgeStaleVariants(forSourceKey sourceKey: String, keeping cachedURL: URL) {
|
private func purgeStaleVariants(forSourceKey sourceKey: String, keeping cachedURL: URL) {
|
||||||
guard let cachedFiles = try? fileManager.contentsOfDirectory(
|
guard let cachedFiles = try? fileManager.contentsOfDirectory(
|
||||||
at: cacheDirectoryURL,
|
at: cacheDirectoryURL,
|
||||||
|
|||||||
@ -23,13 +23,33 @@ struct SidebarFooterState {
|
|||||||
let revealURL: URL?
|
let revealURL: URL?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ConnectedDeviceSidebarEntry: Identifiable, Hashable {
|
||||||
|
let device: ConnectedDevice
|
||||||
|
let containers: [DeviceAppContainer]
|
||||||
|
let matchedSourceID: URL?
|
||||||
|
let discoveryErrorDescription: String?
|
||||||
|
|
||||||
|
var id: String { device.id }
|
||||||
|
|
||||||
|
var minecraftContainer: DeviceAppContainer? {
|
||||||
|
containers.first(where: { $0.appID == "com.mojang.minecraftpe" })
|
||||||
|
?? containers.first(where: { $0.minecraftFolderRelativePath != nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasMinecraftContainer: Bool {
|
||||||
|
minecraftContainer != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SourceLibrary: ObservableObject {
|
final class SourceLibrary: ObservableObject {
|
||||||
private static let enrichmentWorkerCount = 4
|
private static let enrichmentWorkerCount = 4
|
||||||
private static let sizeWorkerCount = 2
|
private static let sizeWorkerCount = 2
|
||||||
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
||||||
|
private static let connectedDeviceRefreshInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
@Published var sources: [MinecraftSource] = []
|
@Published var sources: [MinecraftSource] = []
|
||||||
|
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||||
@Published private(set) var sidebarFooterState = SidebarFooterState(
|
@Published private(set) var sidebarFooterState = SidebarFooterState(
|
||||||
style: .idle,
|
style: .idle,
|
||||||
title: "",
|
title: "",
|
||||||
@ -40,20 +60,47 @@ final class SourceLibrary: ObservableObject {
|
|||||||
@Published private(set) var isRestoringPersistedSources = true
|
@Published private(set) var isRestoringPersistedSources = true
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
|
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
||||||
private var footerResetTask: Task<Void, Never>?
|
private var footerResetTask: Task<Void, Never>?
|
||||||
private let persistenceStore: SourcePersistenceStore
|
private let persistenceStore: SourcePersistenceStore
|
||||||
private let sourceAccessMethod: SourceAccessMethod
|
private let sourceAccessMethod: SourceAccessMethod
|
||||||
|
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
||||||
|
private var lastMatchedConnectedSourceIDs: Set<URL> = []
|
||||||
|
|
||||||
init(
|
init(
|
||||||
persistenceStore: SourcePersistenceStore = .shared,
|
persistenceStore: SourcePersistenceStore = .shared,
|
||||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess()
|
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||||
|
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil
|
||||||
) {
|
) {
|
||||||
self.persistenceStore = persistenceStore
|
self.persistenceStore = persistenceStore
|
||||||
self.sourceAccessMethod = sourceAccessMethod
|
self.sourceAccessMethod = sourceAccessMethod
|
||||||
|
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
await self?.restorePersistedSources()
|
await self?.restorePersistedSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connectedDeviceAccessMethod != nil {
|
||||||
|
connectedDeviceRefreshTask = Task { [weak self] in
|
||||||
|
await self?.runConnectedDeviceRefreshLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleSources: [MinecraftSource] {
|
||||||
|
let matchedConnectedSourceIDs = Set(connectedDevices.compactMap(\.matchedSourceID))
|
||||||
|
return sources.filter { source in
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return true
|
||||||
|
case .connectedDevice:
|
||||||
|
return matchedConnectedSourceIDs.contains(source.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localSources: [MinecraftSource] {
|
||||||
|
visibleSources.filter { $0.origin.kind == .localFolder }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSource(at url: URL) -> URL {
|
func addSource(at url: URL) -> URL {
|
||||||
@ -65,12 +112,22 @@ final class SourceLibrary: ObservableObject {
|
|||||||
if source.bookmarkData == nil {
|
if source.bookmarkData == nil {
|
||||||
source.bookmarkData = bookmarkData
|
source.bookmarkData = bookmarkData
|
||||||
}
|
}
|
||||||
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
}
|
}
|
||||||
startScan(for: normalizedURL)
|
startScan(for: normalizedURL)
|
||||||
return normalizedURL
|
return normalizedURL
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)
|
let source = MinecraftSource(
|
||||||
|
folderURL: normalizedURL,
|
||||||
|
bookmarkData: bookmarkData,
|
||||||
|
accessDescriptor: SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier,
|
||||||
|
kind: .localFolder,
|
||||||
|
capabilities: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
)
|
||||||
return addSource(source, shouldPersist: true, shouldScan: true)
|
return addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +136,8 @@ final class SourceLibrary: ObservableObject {
|
|||||||
if sources.contains(where: { $0.id == source.id }) {
|
if sources.contains(where: { $0.id == source.id }) {
|
||||||
updateSource(source.id) { existingSource in
|
updateSource(source.id) { existingSource in
|
||||||
existingSource.origin = source.origin
|
existingSource.origin = source.origin
|
||||||
|
existingSource.accessDescriptor = source.accessDescriptor
|
||||||
|
existingSource.availability = source.availability
|
||||||
if existingSource.bookmarkData == nil {
|
if existingSource.bookmarkData == nil {
|
||||||
existingSource.bookmarkData = source.bookmarkData
|
existingSource.bookmarkData = source.bookmarkData
|
||||||
}
|
}
|
||||||
@ -87,11 +146,13 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sources.append(source)
|
var resolvedSource = source
|
||||||
|
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
||||||
|
sources.append(resolvedSource)
|
||||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldPersist, source.origin.kind == .localFolder {
|
if shouldPersist {
|
||||||
persistSourceIfAvailable(withID: source.id)
|
persistSourceIfAvailable(withID: source.id)
|
||||||
}
|
}
|
||||||
if shouldScan {
|
if shouldScan {
|
||||||
@ -109,11 +170,23 @@ final class SourceLibrary: ObservableObject {
|
|||||||
startScan(for: sourceID)
|
startScan(for: sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||||
|
try await sourceAccessMethod.listItemContents(for: item, in: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func materializeItem(_ item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
try await sourceAccessMethod.materializeItem(for: item, in: source)
|
||||||
|
}
|
||||||
|
|
||||||
func removeSource(withID sourceID: URL) {
|
func removeSource(withID sourceID: URL) {
|
||||||
|
let removedSource = source(withID: sourceID)
|
||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
scanTasks[sourceID] = nil
|
scanTasks[sourceID] = nil
|
||||||
sources.removeAll { $0.id == sourceID }
|
sources.removeAll { $0.id == sourceID }
|
||||||
deletePersistedSource(withID: sourceID)
|
deletePersistedSource(withID: sourceID)
|
||||||
|
if let removedSource {
|
||||||
|
purgeCachedArtifacts(for: removedSource)
|
||||||
|
}
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,45 +264,10 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let preparedScanRoot: PreparedScanRoot
|
|
||||||
do {
|
|
||||||
preparedScanRoot = try await sourceAccessMethod.prepareScanRoot(for: source)
|
|
||||||
} catch {
|
|
||||||
updateSource(sourceID) { source in
|
|
||||||
source.scanError = error.localizedDescription
|
|
||||||
source.scanStatus = ""
|
|
||||||
source.isScanning = false
|
|
||||||
}
|
|
||||||
refreshSidebarFooterState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let scanRootURL = preparedScanRoot.rootURL
|
|
||||||
let accessedSecurityScope = scanRootURL.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if accessedSecurityScope {
|
|
||||||
scanRootURL.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupPreparedScanRoot(preparedScanRoot)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: scanRootURL.path) else {
|
|
||||||
updateSource(sourceID) { source in
|
|
||||||
source.scanError = "Source folder is no longer available."
|
|
||||||
source.scanStatus = ""
|
|
||||||
source.isScanning = false
|
|
||||||
}
|
|
||||||
refreshSidebarFooterState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await WorldScanner.beginScanSession(for: sourceID)
|
|
||||||
|
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.isScanning = true
|
source.isScanning = true
|
||||||
source.scanError = nil
|
source.scanError = nil
|
||||||
source.scanStatus = "Scanning Minecraft library..."
|
source.scanStatus = initialScanStatus(for: source)
|
||||||
source.displayItems = []
|
source.displayItems = []
|
||||||
source.rawItems = []
|
source.rawItems = []
|
||||||
source.logicalPacks = []
|
source.logicalPacks = []
|
||||||
@ -242,8 +280,30 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
|
}
|
||||||
|
let currentAvailability = await sourceAccessMethod.availability(for: source)
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.availability = currentAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanContextURL = source.folderURL
|
||||||
|
await WorldScanner.beginScanSession(for: scanContextURL)
|
||||||
|
defer {
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
await WorldScanner.endScanSession(for: scanContextURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
source.availability = .available
|
||||||
|
source.scanStatus = scanningLibraryStatus(for: source)
|
||||||
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanRootURL)
|
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
|
||||||
let enrichmentQueue = EnrichmentWorkQueue()
|
let enrichmentQueue = EnrichmentWorkQueue()
|
||||||
let sizeQueue = EnrichmentWorkQueue()
|
let sizeQueue = EnrichmentWorkQueue()
|
||||||
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in
|
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in
|
||||||
@ -257,7 +317,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let enrichedItem = await WorldScanner.enrich(item: item)
|
let enrichedItem = await library.sourceAccessMethod.enrich(item, for: source)
|
||||||
if let snapshot = await index.applyEnrichedItem(enrichedItem) {
|
if let snapshot = await index.applyEnrichedItem(enrichedItem) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
library.applySnapshot(snapshot, to: sourceID)
|
library.applySnapshot(snapshot, to: sourceID)
|
||||||
@ -279,7 +339,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let sizedItem = WorldScanner.loadSize(for: item)
|
let sizedItem = await library.sourceAccessMethod.loadSize(for: item, in: source)
|
||||||
if let snapshot = await index.applySizedItem(sizedItem) {
|
if let snapshot = await index.applySizedItem(sizedItem) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
library.applySnapshot(snapshot, to: sourceID)
|
library.applySnapshot(snapshot, to: sourceID)
|
||||||
@ -290,9 +350,10 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
|
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
|
||||||
|
let accessMethod = sourceAccessMethod
|
||||||
let discoveryTask = Task.detached(priority: .userInitiated) {
|
let discoveryTask = Task.detached(priority: .userInitiated) {
|
||||||
do {
|
do {
|
||||||
_ = try WorldScanner.discoverItems(in: scanRootURL) { item in
|
_ = try await accessMethod.discoverItems(for: source) { item in
|
||||||
continuation.yield(item)
|
continuation.yield(item)
|
||||||
}
|
}
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
@ -353,7 +414,11 @@ final class SourceLibrary: ObservableObject {
|
|||||||
applySnapshot(snapshot, to: sourceID)
|
applySnapshot(snapshot, to: sourceID)
|
||||||
}
|
}
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.snapshot = buildSnapshot(for: source, packMetadataByItemID: [:])
|
if source.origin.kind == .localFolder {
|
||||||
|
source.snapshot = buildSnapshot(for: source, scanRootURL: scanContextURL, packMetadataByItemID: [:])
|
||||||
|
} else {
|
||||||
|
source.snapshot = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
persistSourceIfAvailable(withID: sourceID)
|
persistSourceIfAvailable(withID: sourceID)
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
@ -363,10 +428,12 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
|
source.availability = availabilityStatus(for: error, defaultingTo: source.availability)
|
||||||
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
|
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||||
source.scanStatus = ""
|
source.scanStatus = ""
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
}
|
}
|
||||||
|
persistSourceIfAvailable(withID: sourceID)
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -751,6 +818,214 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func runConnectedDeviceRefreshLoop() async {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await refreshConnectedDevices()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshInterval))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshConnectedDevices() async {
|
||||||
|
guard let connectedDeviceAccessMethod else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices: [ConnectedDevice]
|
||||||
|
do {
|
||||||
|
devices = try await connectedDeviceAccessMethod.listConnectedDevices()
|
||||||
|
} catch {
|
||||||
|
markAllConnectedDeviceSourcesDisconnected()
|
||||||
|
connectedDevices = []
|
||||||
|
lastMatchedConnectedSourceIDs = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries: [ConnectedDeviceSidebarEntry] = []
|
||||||
|
var matchedSourceIDs = Set<URL>()
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
|
if let matchedSourceID = knownConnectedDeviceSourceID(for: device) {
|
||||||
|
matchedSourceIDs.insert(matchedSourceID)
|
||||||
|
refreshMatchedConnectedDeviceSource(
|
||||||
|
sourceID: matchedSourceID,
|
||||||
|
device: device,
|
||||||
|
containers: []
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
ConnectedDeviceSidebarEntry(
|
||||||
|
device: device,
|
||||||
|
containers: [],
|
||||||
|
matchedSourceID: matchedSourceID,
|
||||||
|
discoveryErrorDescription: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let containers: [DeviceAppContainer]
|
||||||
|
let discoveryErrorDescription: String?
|
||||||
|
|
||||||
|
do {
|
||||||
|
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
|
||||||
|
discoveryErrorDescription = nil
|
||||||
|
} catch {
|
||||||
|
containers = []
|
||||||
|
discoveryErrorDescription = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedSourceID = matchingConnectedDeviceSourceID(
|
||||||
|
device: device,
|
||||||
|
containers: containers
|
||||||
|
)
|
||||||
|
|
||||||
|
if let matchedSourceID {
|
||||||
|
matchedSourceIDs.insert(matchedSourceID)
|
||||||
|
refreshMatchedConnectedDeviceSource(
|
||||||
|
sourceID: matchedSourceID,
|
||||||
|
device: device,
|
||||||
|
containers: containers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldDisplayEntry =
|
||||||
|
matchedSourceID != nil
|
||||||
|
|| !containers.isEmpty
|
||||||
|
|| device.trustState != .trusted
|
||||||
|
|
||||||
|
if shouldDisplayEntry {
|
||||||
|
entries.append(
|
||||||
|
ConnectedDeviceSidebarEntry(
|
||||||
|
device: device,
|
||||||
|
containers: containers,
|
||||||
|
matchedSourceID: matchedSourceID,
|
||||||
|
discoveryErrorDescription: discoveryErrorDescription
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markDisconnectedConnectedDeviceSources(excluding: matchedSourceIDs)
|
||||||
|
|
||||||
|
connectedDevices = entries.sorted {
|
||||||
|
let lhsKnown = $0.matchedSourceID != nil
|
||||||
|
let rhsKnown = $1.matchedSourceID != nil
|
||||||
|
if lhsKnown != rhsKnown {
|
||||||
|
return lhsKnown && !rhsKnown
|
||||||
|
}
|
||||||
|
|
||||||
|
let lhsMinecraft = $0.hasMinecraftContainer
|
||||||
|
let rhsMinecraft = $1.hasMinecraftContainer
|
||||||
|
if lhsMinecraft != rhsMinecraft {
|
||||||
|
return lhsMinecraft && !rhsMinecraft
|
||||||
|
}
|
||||||
|
|
||||||
|
return $0.device.name.localizedStandardCompare($1.device.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMatchedConnectedSourceIDs = matchedSourceIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchingConnectedDeviceSourceID(
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containers: [DeviceAppContainer]
|
||||||
|
) -> URL? {
|
||||||
|
for source in sources {
|
||||||
|
guard case .connectedDevice(let expectedDevice, let expectedContainer) = source.origin else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard expectedDevice.udid == device.udid else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard containers.contains(where: { container in
|
||||||
|
container.appID == expectedContainer.appID
|
||||||
|
&& container.accessMode == expectedContainer.accessMode
|
||||||
|
}) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? {
|
||||||
|
for source in sources {
|
||||||
|
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard expectedDevice.udid == device.udid else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshMatchedConnectedDeviceSource(
|
||||||
|
sourceID: URL,
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containers: [DeviceAppContainer]
|
||||||
|
) {
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
guard case .connectedDevice(_, let previousContainer) = source.origin else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedContainer = containers.first(where: {
|
||||||
|
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
||||||
|
}) ?? previousContainer
|
||||||
|
|
||||||
|
source.origin = .connectedDevice(device: device, container: resolvedContainer)
|
||||||
|
source.displayName = "\(device.name) • \(resolvedContainer.appName)"
|
||||||
|
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||||
|
source.availability = availability(for: device, hasMinecraftContainer: true)
|
||||||
|
}
|
||||||
|
persistSourceIfAvailable(withID: sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markAllConnectedDeviceSourcesDisconnected() {
|
||||||
|
for source in sources where source.origin.kind == .connectedDevice {
|
||||||
|
updateSource(source.id) { source in
|
||||||
|
source.availability = .disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markDisconnectedConnectedDeviceSources(excluding matchedSourceIDs: Set<URL>) {
|
||||||
|
for source in sources where source.origin.kind == .connectedDevice && !matchedSourceIDs.contains(source.id) {
|
||||||
|
updateSource(source.id) { source in
|
||||||
|
source.availability = .disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func availability(for device: ConnectedDevice, hasMinecraftContainer: Bool) -> SourceAvailability {
|
||||||
|
guard hasMinecraftContainer else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
switch device.trustState {
|
||||||
|
case .trusted:
|
||||||
|
return .available
|
||||||
|
case .locked, .untrusted:
|
||||||
|
return .limited
|
||||||
|
case .unavailable:
|
||||||
|
return .disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func restorePersistedSources() async {
|
private func restorePersistedSources() async {
|
||||||
defer {
|
defer {
|
||||||
isRestoringPersistedSources = false
|
isRestoringPersistedSources = false
|
||||||
@ -765,7 +1040,14 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for record in records {
|
for record in records {
|
||||||
var source = MinecraftSource(folderURL: record.folderURL, bookmarkData: record.bookmarkData)
|
var source = MinecraftSource(
|
||||||
|
sourceID: record.sourceID,
|
||||||
|
folderURL: record.folderURL,
|
||||||
|
bookmarkData: record.bookmarkData,
|
||||||
|
origin: record.origin,
|
||||||
|
accessDescriptor: record.accessDescriptor,
|
||||||
|
availability: record.availability
|
||||||
|
)
|
||||||
source.displayName = record.displayName
|
source.displayName = record.displayName
|
||||||
source.rawItems = await restoreCachedImages(in: record.rawItems)
|
source.rawItems = await restoreCachedImages(in: record.rawItems)
|
||||||
source.indexedItemCount = record.rawItems.count
|
source.indexedItemCount = record.rawItems.count
|
||||||
@ -788,9 +1070,11 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
for record in records {
|
for record in records {
|
||||||
if sourceNeedsRescan(record) {
|
if sourceNeedsRescan(record) {
|
||||||
startScan(for: record.folderURL)
|
startScan(for: record.sourceID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshConnectedDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] {
|
private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] {
|
||||||
@ -828,6 +1112,10 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool {
|
private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool {
|
||||||
|
guard record.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
||||||
|
return record.rawItems.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
guard let snapshot = record.snapshot else {
|
guard let snapshot = record.snapshot else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -949,9 +1237,14 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deletePersistedSource(withID sourceID: URL) {
|
private func deletePersistedSource(withID sourceID: URL) {
|
||||||
let normalizedSourceID = sourceID.standardizedFileURL
|
|
||||||
Task {
|
Task {
|
||||||
try? await persistenceStore.deleteSource(withID: normalizedSourceID)
|
try? await persistenceStore.deleteSource(withID: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purgeCachedArtifacts(for source: MinecraftSource) {
|
||||||
|
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
||||||
|
await sourceAccessMethod.purgeCachedArtifacts(for: source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -967,12 +1260,6 @@ final class SourceLibrary: ObservableObject {
|
|||||||
contentType == .behaviorPack || contentType == .resourcePack
|
contentType == .behaviorPack || contentType == .resourcePack
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
|
|
||||||
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
|
||||||
await sourceAccessMethod.releaseScanRoot(preparedScanRoot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshSidebarFooterState() {
|
private func refreshSidebarFooterState() {
|
||||||
if isRestoringPersistedSources {
|
if isRestoringPersistedSources {
|
||||||
cancelFooterReset()
|
cancelFooterReset()
|
||||||
@ -1030,6 +1317,24 @@ final class SourceLibrary: ObservableObject {
|
|||||||
footerResetTask = nil
|
footerResetTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func initialScanStatus(for source: MinecraftSource) -> String {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return "Preparing folder scan..."
|
||||||
|
case .connectedDevice:
|
||||||
|
return "Connecting to device and discovering Minecraft items..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scanningLibraryStatus(for source: MinecraftSource) -> String {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return "Scanning Minecraft library..."
|
||||||
|
case .connectedDevice:
|
||||||
|
return "Scanning Minecraft library on device..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleFooterReset(after seconds: Double = 5) {
|
private func scheduleFooterReset(after seconds: Double = 5) {
|
||||||
cancelFooterReset()
|
cancelFooterReset()
|
||||||
footerResetTask = Task { @MainActor [weak self] in
|
footerResetTask = Task { @MainActor [weak self] in
|
||||||
@ -1044,10 +1349,11 @@ final class SourceLibrary: ObservableObject {
|
|||||||
|
|
||||||
private func buildSnapshot(
|
private func buildSnapshot(
|
||||||
for source: MinecraftSource,
|
for source: MinecraftSource,
|
||||||
|
scanRootURL: URL,
|
||||||
packMetadataByItemID: [URL: PackMetadata]
|
packMetadataByItemID: [URL: PackMetadata]
|
||||||
) -> SourceSnapshot {
|
) -> SourceSnapshot {
|
||||||
let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in
|
let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in
|
||||||
let collectionURL = source.folderURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
let collectionURL = scanRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||||
guard FileManager.default.fileExists(atPath: collectionURL.path) else {
|
guard FileManager.default.fileExists(atPath: collectionURL.path) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -1075,7 +1381,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let itemSnapshots = source.rawItems.map { item in
|
let itemSnapshots = source.rawItems.map { item in
|
||||||
let relativePath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "")
|
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
|
||||||
let metadata = packMetadataByItemID[item.id]
|
let metadata = packMetadataByItemID[item.id]
|
||||||
return ItemSnapshot(
|
return ItemSnapshot(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -1089,7 +1395,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
|
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootModifiedDate = try? source.folderURL
|
let rootModifiedDate = try? scanRootURL
|
||||||
.resourceValues(forKeys: [.contentModificationDateKey])
|
.resourceValues(forKeys: [.contentModificationDateKey])
|
||||||
.contentModificationDate
|
.contentModificationDate
|
||||||
|
|
||||||
@ -1101,6 +1407,21 @@ final class SourceLibrary: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func availabilityStatus(for error: Error, defaultingTo currentAvailability: SourceAvailability) -> SourceAvailability {
|
||||||
|
if let accessError = error as? SourceAccessError {
|
||||||
|
switch accessError {
|
||||||
|
case .deviceUnavailable:
|
||||||
|
return .disconnected
|
||||||
|
case .deviceNotTrusted:
|
||||||
|
return .limited
|
||||||
|
case .appNotAccessible, .minecraftFolderMissing, .accessFailed:
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentAvailability
|
||||||
|
}
|
||||||
|
|
||||||
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
||||||
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
||||||
let existingEmbedded = isEmbeddedWorldPack(existing)
|
let existingEmbedded = isEmbeddedWorldPack(existing)
|
||||||
|
|||||||
@ -9,7 +9,11 @@ import Foundation
|
|||||||
import SQLite3
|
import SQLite3
|
||||||
|
|
||||||
struct PersistedSourceRecord: Sendable {
|
struct PersistedSourceRecord: Sendable {
|
||||||
|
let sourceID: URL
|
||||||
let folderURL: URL
|
let folderURL: URL
|
||||||
|
let origin: MinecraftSourceOrigin
|
||||||
|
let accessDescriptor: SourceAccessDescriptor
|
||||||
|
let availability: SourceAvailability
|
||||||
let bookmarkData: Data?
|
let bookmarkData: Data?
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let rawItems: [MinecraftContentItem]
|
let rawItems: [MinecraftContentItem]
|
||||||
@ -122,13 +126,13 @@ private struct PersistedCollectionSnapshotPayload: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
||||||
let sourcePath: String
|
let sourceIdentifier: String
|
||||||
let rootModifiedDate: Date?
|
let rootModifiedDate: Date?
|
||||||
let collectionSnapshots: [PersistedCollectionSnapshotPayload]
|
let collectionSnapshots: [PersistedCollectionSnapshotPayload]
|
||||||
let itemSnapshots: [PersistedItemSnapshotPayload]
|
let itemSnapshots: [PersistedItemSnapshotPayload]
|
||||||
|
|
||||||
nonisolated init(_ snapshot: SourceSnapshot) {
|
nonisolated init(_ snapshot: SourceSnapshot) {
|
||||||
self.sourcePath = snapshot.sourceID.path
|
self.sourceIdentifier = snapshot.sourceID.absoluteString
|
||||||
self.rootModifiedDate = snapshot.rootModifiedDate
|
self.rootModifiedDate = snapshot.rootModifiedDate
|
||||||
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
|
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
|
||||||
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
|
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
|
||||||
@ -136,7 +140,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
|||||||
|
|
||||||
nonisolated var sourceSnapshot: SourceSnapshot {
|
nonisolated var sourceSnapshot: SourceSnapshot {
|
||||||
SourceSnapshot(
|
SourceSnapshot(
|
||||||
sourceID: URL(fileURLWithPath: sourcePath),
|
sourceID: URL(string: sourceIdentifier) ?? URL(fileURLWithPath: sourceIdentifier),
|
||||||
rootModifiedDate: rootModifiedDate,
|
rootModifiedDate: rootModifiedDate,
|
||||||
collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot),
|
collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot),
|
||||||
itemSnapshots: itemSnapshots.map(\.itemSnapshot)
|
itemSnapshots: itemSnapshots.map(\.itemSnapshot)
|
||||||
@ -145,7 +149,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
|||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.sourcePath = try container.decode(String.self, forKey: .sourcePath)
|
self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier)
|
||||||
self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
|
self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
|
||||||
self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
|
self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
|
||||||
self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
|
self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
|
||||||
@ -153,14 +157,14 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
|||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(sourcePath, forKey: .sourcePath)
|
try container.encode(sourceIdentifier, forKey: .sourceIdentifier)
|
||||||
try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
|
try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
|
||||||
try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
|
try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
|
||||||
try container.encode(itemSnapshots, forKey: .itemSnapshots)
|
try container.encode(itemSnapshots, forKey: .itemSnapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case sourcePath
|
case sourceIdentifier
|
||||||
case rootModifiedDate
|
case rootModifiedDate
|
||||||
case collectionSnapshots
|
case collectionSnapshots
|
||||||
case itemSnapshots
|
case itemSnapshots
|
||||||
@ -189,7 +193,8 @@ actor SourcePersistenceStore {
|
|||||||
defer { sqlite3_close(database) }
|
defer { sqlite3_close(database) }
|
||||||
|
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT folder_path, bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
|
SELECT source_id, folder_path, origin_json, access_descriptor_json, availability_state,
|
||||||
|
bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
|
||||||
FROM source_cache
|
FROM source_cache
|
||||||
ORDER BY display_name COLLATE NOCASE ASC;
|
ORDER BY display_name COLLATE NOCASE ASC;
|
||||||
"""
|
"""
|
||||||
@ -203,23 +208,38 @@ actor SourcePersistenceStore {
|
|||||||
var records: [PersistedSourceRecord] = []
|
var records: [PersistedSourceRecord] = []
|
||||||
|
|
||||||
while sqlite3_step(statement) == SQLITE_ROW {
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||||||
guard let folderPathPointer = sqlite3_column_text(statement, 0) else {
|
guard let folderPathPointer = sqlite3_column_text(statement, 1) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
|
||||||
let folderPath = String(cString: folderPathPointer)
|
let folderPath = String(cString: folderPathPointer)
|
||||||
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 1)
|
let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: 2)
|
||||||
let displayName = String(cString: sqlite3_column_text(statement, 2))
|
?? .localFolder(bookmarkData: nil)
|
||||||
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 3) ?? []
|
let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: 3)
|
||||||
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 4)
|
?? SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: origin.defaultAccessorIdentifier,
|
||||||
|
kind: origin.kind,
|
||||||
|
capabilities: origin.defaultCapabilities,
|
||||||
|
refreshStrategy: origin.defaultRefreshStrategy
|
||||||
|
)
|
||||||
|
let availability = decodeAvailability(statement: statement, columnIndex: 4)
|
||||||
|
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
|
||||||
|
let displayName = String(cString: sqlite3_column_text(statement, 6))
|
||||||
|
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 7) ?? []
|
||||||
|
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 8)
|
||||||
let snapshot = snapshotPayload?.sourceSnapshot
|
let snapshot = snapshotPayload?.sourceSnapshot
|
||||||
let lastScanDate = sqlite3_column_type(statement, 5) == SQLITE_NULL
|
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
|
||||||
? nil
|
? nil
|
||||||
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 5))
|
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
|
||||||
|
|
||||||
records.append(
|
records.append(
|
||||||
PersistedSourceRecord(
|
PersistedSourceRecord(
|
||||||
|
sourceID: sourceID,
|
||||||
folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL,
|
folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL,
|
||||||
|
origin: origin,
|
||||||
|
accessDescriptor: accessDescriptor,
|
||||||
|
availability: availability,
|
||||||
bookmarkData: bookmarkData,
|
bookmarkData: bookmarkData,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
rawItems: rawItems,
|
rawItems: rawItems,
|
||||||
@ -238,15 +258,23 @@ actor SourcePersistenceStore {
|
|||||||
|
|
||||||
let sql = """
|
let sql = """
|
||||||
INSERT INTO source_cache (
|
INSERT INTO source_cache (
|
||||||
|
source_id,
|
||||||
folder_path,
|
folder_path,
|
||||||
|
origin_json,
|
||||||
|
access_descriptor_json,
|
||||||
|
availability_state,
|
||||||
bookmark_data,
|
bookmark_data,
|
||||||
display_name,
|
display_name,
|
||||||
raw_items_json,
|
raw_items_json,
|
||||||
snapshot_json,
|
snapshot_json,
|
||||||
last_scan_date
|
last_scan_date
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(folder_path) DO UPDATE SET
|
ON CONFLICT(source_id) DO UPDATE SET
|
||||||
bookmark_data = excluded.bookmark_data,
|
bookmark_data = excluded.bookmark_data,
|
||||||
|
folder_path = excluded.folder_path,
|
||||||
|
origin_json = excluded.origin_json,
|
||||||
|
access_descriptor_json = excluded.access_descriptor_json,
|
||||||
|
availability_state = excluded.availability_state,
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
raw_items_json = excluded.raw_items_json,
|
raw_items_json = excluded.raw_items_json,
|
||||||
snapshot_json = excluded.snapshot_json,
|
snapshot_json = excluded.snapshot_json,
|
||||||
@ -259,16 +287,20 @@ actor SourcePersistenceStore {
|
|||||||
}
|
}
|
||||||
defer { sqlite3_finalize(statement) }
|
defer { sqlite3_finalize(statement) }
|
||||||
|
|
||||||
try bindText(source.folderURL.path, to: statement, at: 1)
|
try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1)
|
||||||
try bindData(source.bookmarkData, to: statement, at: 2)
|
try bindText(source.folderURL.path, to: statement, at: 2)
|
||||||
try bindText(source.displayName, to: statement, at: 3)
|
try bindJSON(source.origin, to: statement, at: 3)
|
||||||
try bindJSON(source.rawItems, to: statement, at: 4)
|
try bindJSON(source.accessDescriptor, to: statement, at: 4)
|
||||||
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 5)
|
try bindText(source.availability.rawValue, to: statement, at: 5)
|
||||||
|
try bindData(source.bookmarkData, to: statement, at: 6)
|
||||||
|
try bindText(source.displayName, to: statement, at: 7)
|
||||||
|
try bindJSON(source.rawItems, to: statement, at: 8)
|
||||||
|
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)
|
||||||
|
|
||||||
if let lastScanDate = source.lastScanDate {
|
if let lastScanDate = source.lastScanDate {
|
||||||
sqlite3_bind_double(statement, 6, lastScanDate.timeIntervalSince1970)
|
sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
|
||||||
} else {
|
} else {
|
||||||
sqlite3_bind_null(statement, 6)
|
sqlite3_bind_null(statement, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||||
@ -280,14 +312,15 @@ actor SourcePersistenceStore {
|
|||||||
let database = try openDatabase()
|
let database = try openDatabase()
|
||||||
defer { sqlite3_close(database) }
|
defer { sqlite3_close(database) }
|
||||||
|
|
||||||
let sql = "DELETE FROM source_cache WHERE folder_path = ?;"
|
let sql = "DELETE FROM source_cache WHERE source_id = ? OR folder_path = ?;"
|
||||||
var statement: OpaquePointer?
|
var statement: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||||
throw databaseError(database)
|
throw databaseError(database)
|
||||||
}
|
}
|
||||||
defer { sqlite3_finalize(statement) }
|
defer { sqlite3_finalize(statement) }
|
||||||
|
|
||||||
try bindText(sourceID.standardizedFileURL.path, to: statement, at: 1)
|
try bindText(normalizedIdentifierText(for: sourceID), to: statement, at: 1)
|
||||||
|
try bindText(sourceID.isFileURL ? sourceID.standardizedFileURL.path : sourceID.path, to: statement, at: 2)
|
||||||
|
|
||||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||||
throw databaseError(database)
|
throw databaseError(database)
|
||||||
@ -309,7 +342,11 @@ actor SourcePersistenceStore {
|
|||||||
try execute(
|
try execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS source_cache (
|
CREATE TABLE IF NOT EXISTS source_cache (
|
||||||
|
source_id TEXT,
|
||||||
folder_path TEXT PRIMARY KEY,
|
folder_path TEXT PRIMARY KEY,
|
||||||
|
origin_json BLOB,
|
||||||
|
access_descriptor_json BLOB,
|
||||||
|
availability_state TEXT,
|
||||||
bookmark_data BLOB,
|
bookmark_data BLOB,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
raw_items_json BLOB NOT NULL,
|
raw_items_json BLOB NOT NULL,
|
||||||
@ -319,15 +356,59 @@ actor SourcePersistenceStore {
|
|||||||
""",
|
""",
|
||||||
on: database
|
on: database
|
||||||
)
|
)
|
||||||
|
let existingColumns = try columns(in: "source_cache", on: database)
|
||||||
|
try addColumnIfNeeded("bookmark_data", sql: "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", existingColumns: existingColumns, on: database)
|
||||||
|
try addColumnIfNeeded("source_id", sql: "ALTER TABLE source_cache ADD COLUMN source_id TEXT;", existingColumns: existingColumns, on: database)
|
||||||
|
try addColumnIfNeeded("origin_json", sql: "ALTER TABLE source_cache ADD COLUMN origin_json BLOB;", existingColumns: existingColumns, on: database)
|
||||||
|
try addColumnIfNeeded("access_descriptor_json", sql: "ALTER TABLE source_cache ADD COLUMN access_descriptor_json BLOB;", existingColumns: existingColumns, on: database)
|
||||||
|
try addColumnIfNeeded("availability_state", sql: "ALTER TABLE source_cache ADD COLUMN availability_state TEXT;", existingColumns: existingColumns, on: database)
|
||||||
try execute(
|
try execute(
|
||||||
"ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;",
|
"""
|
||||||
on: database,
|
UPDATE source_cache
|
||||||
ignoringDuplicateColumn: true
|
SET source_id = folder_path
|
||||||
|
WHERE source_id IS NULL OR source_id = '';
|
||||||
|
""",
|
||||||
|
on: database
|
||||||
|
)
|
||||||
|
try execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS source_cache_source_id_idx ON source_cache(source_id);",
|
||||||
|
on: database
|
||||||
)
|
)
|
||||||
|
|
||||||
return database
|
return database
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func columns(in tableName: String, on database: OpaquePointer?) throws -> Set<String> {
|
||||||
|
let sql = "PRAGMA table_info(\(tableName));"
|
||||||
|
var statement: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||||
|
throw databaseError(database)
|
||||||
|
}
|
||||||
|
defer { sqlite3_finalize(statement) }
|
||||||
|
|
||||||
|
var columns = Set<String>()
|
||||||
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||||||
|
if let namePointer = sqlite3_column_text(statement, 1) {
|
||||||
|
columns.insert(String(cString: namePointer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addColumnIfNeeded(
|
||||||
|
_ columnName: String,
|
||||||
|
sql: String,
|
||||||
|
existingColumns: Set<String>,
|
||||||
|
on database: OpaquePointer?
|
||||||
|
) throws {
|
||||||
|
guard !existingColumns.contains(columnName) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try execute(sql, on: database)
|
||||||
|
}
|
||||||
|
|
||||||
private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws {
|
private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws {
|
||||||
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||||||
if ignoringDuplicateColumn,
|
if ignoringDuplicateColumn,
|
||||||
@ -401,6 +482,34 @@ actor SourcePersistenceStore {
|
|||||||
return Data(bytes: bytes, count: byteCount)
|
return Data(bytes: bytes, count: byteCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sourceID(from statement: OpaquePointer?) -> URL? {
|
||||||
|
guard let pointer = sqlite3_column_text(statement, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = String(cString: pointer)
|
||||||
|
return URL(string: value) ?? URL(fileURLWithPath: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeAvailability(statement: OpaquePointer?, columnIndex: Int32) -> SourceAvailability {
|
||||||
|
guard
|
||||||
|
let pointer = sqlite3_column_text(statement, columnIndex),
|
||||||
|
let availability = SourceAvailability(rawValue: String(cString: pointer))
|
||||||
|
else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedIdentifierText(for sourceID: URL) -> String {
|
||||||
|
if sourceID.isFileURL {
|
||||||
|
return sourceID.standardizedFileURL.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceID.standardized.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
private func databaseError(_ database: OpaquePointer?) -> Error {
|
private func databaseError(_ database: OpaquePointer?) -> Error {
|
||||||
persistenceError(String(cString: sqlite3_errmsg(database)))
|
persistenceError(String(cString: sqlite3_errmsg(database)))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,42 +21,39 @@ struct SidebarFilter: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SourcesSidebarView: View {
|
struct SourcesSidebarView: View {
|
||||||
let sources: [MinecraftSource]
|
let localSources: [MinecraftSource]
|
||||||
|
let connectedDevices: [ConnectedDeviceSidebarEntry]
|
||||||
@Binding var selection: SidebarSelection?
|
@Binding var selection: SidebarSelection?
|
||||||
let footerState: SidebarFooterState
|
let footerState: SidebarFooterState
|
||||||
let addSourceAction: () -> Void
|
let addSourceAction: () -> Void
|
||||||
let addDeviceSourceAction: () -> Void
|
let addDeviceSourceAction: () -> Void
|
||||||
|
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||||
let rescanSourceAction: (MinecraftSource) -> Void
|
let rescanSourceAction: (MinecraftSource) -> Void
|
||||||
let removeSourceAction: (MinecraftSource) -> Void
|
let removeSourceAction: (MinecraftSource) -> Void
|
||||||
let revealFooterURLAction: (URL) -> Void
|
let revealFooterURLAction: (URL) -> Void
|
||||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||||
|
let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
Section {
|
if !localSources.isEmpty {
|
||||||
ForEach(sources) { source in
|
Section {
|
||||||
SourceHeaderRow(title: source.displayName)
|
ForEach(localSources) { source in
|
||||||
.listRowSeparator(.hidden)
|
sourceSectionRows(for: source)
|
||||||
.padding(.top, 6)
|
|
||||||
.contextMenu {
|
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
|
||||||
rescanSourceAction(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
|
||||||
removeSourceAction(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(filters(source)) { filter in
|
|
||||||
SidebarFilterRow(filter: filter, isIndented: true)
|
|
||||||
.tag(filter.selection as SidebarSelection?)
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView(title: "Libraries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connectedDevices.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(connectedDevices) { entry in
|
||||||
|
connectedDeviceSectionRows(for: entry)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView(title: "Connected Devices")
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
SidebarSourcesSectionHeaderView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
@ -88,6 +85,45 @@ struct SourcesSidebarView: View {
|
|||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
||||||
|
SourceHeaderRow(title: source.displayName)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
|
rescanSourceAction(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
|
removeSourceAction(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(filters(source)) { filter in
|
||||||
|
SidebarFilterRow(filter: filter, isIndented: true)
|
||||||
|
.tag(filter.selection as SidebarSelection?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
||||||
|
if let source = matchedSource(entry) {
|
||||||
|
sourceSectionRows(for: source)
|
||||||
|
} else {
|
||||||
|
ConnectedDeviceRow(
|
||||||
|
entry: entry,
|
||||||
|
addAction: entry.hasMinecraftContainer ? {
|
||||||
|
addConnectedDeviceAction(entry)
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SidebarFilterRow: View {
|
private struct SidebarFilterRow: View {
|
||||||
@ -112,8 +148,10 @@ private struct SidebarFilterRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct SidebarSourcesSectionHeaderView: View {
|
private struct SidebarSourcesSectionHeaderView: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Libraries")
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textCase(nil)
|
.textCase(nil)
|
||||||
@ -130,6 +168,84 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ConnectedDeviceRow: View {
|
||||||
|
let entry: ConnectedDeviceSidebarEntry
|
||||||
|
let addAction: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.frame(width: 16)
|
||||||
|
.foregroundStyle(iconColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(entry.device.name)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
|
||||||
|
Text(statusText)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
if let addAction {
|
||||||
|
Button("Add") {
|
||||||
|
addAction()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(addAction == nil ? 0.68 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
if entry.hasMinecraftContainer {
|
||||||
|
return "iphone.gen3"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entry.device.trustState {
|
||||||
|
case .trusted:
|
||||||
|
return "iphone.slash"
|
||||||
|
case .locked, .untrusted:
|
||||||
|
return "lock.iphone"
|
||||||
|
case .unavailable:
|
||||||
|
return "iphone.gen3.slash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
entry.hasMinecraftContainer ? .appAccent : .secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleColor: Color {
|
||||||
|
addAction == nil ? .secondary : .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
if let errorDescription = entry.discoveryErrorDescription, !errorDescription.isEmpty {
|
||||||
|
return errorDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entry.device.trustState {
|
||||||
|
case .trusted:
|
||||||
|
if entry.hasMinecraftContainer, let container = entry.minecraftContainer {
|
||||||
|
return "Minecraft found in \(container.appName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "No Minecraft source found"
|
||||||
|
case .locked:
|
||||||
|
return "Unlock this device to inspect apps"
|
||||||
|
case .untrusted:
|
||||||
|
return "Trust this device to inspect apps"
|
||||||
|
case .unavailable:
|
||||||
|
return "Device unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SidebarFooterView: View {
|
private struct SidebarFooterView: View {
|
||||||
let state: SidebarFooterState
|
let state: SidebarFooterState
|
||||||
let revealAction: (URL) -> Void
|
let revealAction: (URL) -> Void
|
||||||
|
|||||||
@ -22,6 +22,22 @@ struct AppleMobileDeviceApplicationSummary: Sendable {
|
|||||||
let supportsOpeningDocumentsInPlace: Bool
|
let supportsOpeningDocumentsInPlace: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppleMobileMinecraftLibraryItemSummary: Sendable {
|
||||||
|
let contentType: String
|
||||||
|
let collectionFolderName: String
|
||||||
|
let relativePath: String
|
||||||
|
let folderName: String
|
||||||
|
let displayName: String
|
||||||
|
let packUUID: String?
|
||||||
|
let packVersion: String?
|
||||||
|
let minimumEngineVersion: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppleMobileDevicePathMetrics: Sendable {
|
||||||
|
let sizeBytes: Int64?
|
||||||
|
let modifiedDate: Date?
|
||||||
|
}
|
||||||
|
|
||||||
enum AppleMobileDeviceAccess {
|
enum AppleMobileDeviceAccess {
|
||||||
static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary {
|
static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary {
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
@ -113,10 +129,158 @@ enum AppleMobileDeviceAccess {
|
|||||||
return AppleMobileDeviceApplicationSummary(
|
return AppleMobileDeviceApplicationSummary(
|
||||||
bundleIdentifier: bundleIdentifier,
|
bundleIdentifier: bundleIdentifier,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
fileSharingEnabled: application["uiFileSharingEnabled"] as? Bool ?? false,
|
fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
|
||||||
supportsOpeningDocumentsInPlace: application["supportsOpeningDocumentsInPlace"] as? Bool ?? false
|
supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func listDirectory(
|
||||||
|
bundleIdentifier: String,
|
||||||
|
relativePath: String
|
||||||
|
) async throws -> [String] {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
var error: NSError?
|
||||||
|
guard let response = WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||||
|
bundleIdentifier,
|
||||||
|
relativePath,
|
||||||
|
&error
|
||||||
|
) else {
|
||||||
|
throw error ?? NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 7,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fileData(
|
||||||
|
bundleIdentifier: String,
|
||||||
|
relativePath: String
|
||||||
|
) async throws -> Data {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
var error: NSError?
|
||||||
|
guard let data = WMMCopyFirstConnectedDeviceAppFileData(
|
||||||
|
bundleIdentifier,
|
||||||
|
relativePath,
|
||||||
|
&error
|
||||||
|
) else {
|
||||||
|
throw error ?? NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 8,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as Data
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func minecraftLibrarySnapshot(
|
||||||
|
bundleIdentifier: String,
|
||||||
|
relativePath: String
|
||||||
|
) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
var error: NSError?
|
||||||
|
guard let response = WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||||
|
bundleIdentifier,
|
||||||
|
relativePath,
|
||||||
|
&error
|
||||||
|
) else {
|
||||||
|
throw error ?? NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 5,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawItems = response["items"] as? [[String: Any]] else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 6,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawItems.compactMap { item in
|
||||||
|
guard
|
||||||
|
let contentType = item["contentType"] as? String,
|
||||||
|
let collectionFolderName = item["collectionFolderName"] as? String,
|
||||||
|
let relativePath = item["relativePath"] as? String,
|
||||||
|
let folderName = item["folderName"] as? String,
|
||||||
|
let displayName = item["displayName"] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppleMobileMinecraftLibraryItemSummary(
|
||||||
|
contentType: contentType,
|
||||||
|
collectionFolderName: collectionFolderName,
|
||||||
|
relativePath: relativePath,
|
||||||
|
folderName: folderName,
|
||||||
|
displayName: displayName,
|
||||||
|
packUUID: (item["packUUID"] as? String)?.lowercased(),
|
||||||
|
packVersion: item["packVersion"] as? String,
|
||||||
|
minimumEngineVersion: item["minimumEngineVersion"] as? String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pathMetrics(
|
||||||
|
bundleIdentifier: String,
|
||||||
|
relativePath: String
|
||||||
|
) async throws -> AppleMobileDevicePathMetrics {
|
||||||
|
try await Task.detached(priority: .utility) {
|
||||||
|
var error: NSError?
|
||||||
|
guard let response = WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||||
|
bundleIdentifier,
|
||||||
|
relativePath,
|
||||||
|
&error
|
||||||
|
) else {
|
||||||
|
throw error ?? NSError(
|
||||||
|
domain: "AppleMobileDeviceAccess",
|
||||||
|
code: 9,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawSize = response["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 AppleMobileDevicePathMetrics(
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
modifiedDate: response["modifiedDate"] as? Date
|
||||||
|
)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func flexibleBool(from value: Any?) -> Bool {
|
||||||
|
switch value {
|
||||||
|
case let value as Bool:
|
||||||
|
return value
|
||||||
|
case let value as NSNumber:
|
||||||
|
return value.boolValue
|
||||||
|
case let value as NSString:
|
||||||
|
return value.boolValue
|
||||||
|
case let value as String:
|
||||||
|
return NSString(string: value).boolValue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,27 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
|||||||
NSError **error
|
NSError **error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
);
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT NSData * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceAppFileData(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
);
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
);
|
||||||
|
|
||||||
FOUNDATION_EXPORT BOOL
|
FOUNDATION_EXPORT BOOL
|
||||||
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||||
NSString *bundleIdentifier,
|
NSString *bundleIdentifier,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#import <CoreFoundation/CoreFoundation.h>
|
#import <CoreFoundation/CoreFoundation.h>
|
||||||
#import <dlfcn.h>
|
#import <dlfcn.h>
|
||||||
|
#import <limits.h>
|
||||||
|
|
||||||
NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain";
|
NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain";
|
||||||
|
|
||||||
@ -103,6 +104,9 @@ typedef int (*AFCConnectionCloseFn)(AFCConnectionRef connection);
|
|||||||
typedef int (*AFCDirectoryOpenFn)(AFCConnectionRef connection, const char *path, AFCDirectoryRef *directory);
|
typedef int (*AFCDirectoryOpenFn)(AFCConnectionRef connection, const char *path, AFCDirectoryRef *directory);
|
||||||
typedef int (*AFCDirectoryReadFn)(AFCConnectionRef connection, AFCDirectoryRef directory, char **directoryEntry);
|
typedef int (*AFCDirectoryReadFn)(AFCConnectionRef connection, AFCDirectoryRef directory, char **directoryEntry);
|
||||||
typedef int (*AFCDirectoryCloseFn)(AFCConnectionRef connection, AFCDirectoryRef directory);
|
typedef int (*AFCDirectoryCloseFn)(AFCConnectionRef connection, AFCDirectoryRef directory);
|
||||||
|
typedef int (*AFCFileInfoOpenFn)(AFCConnectionRef connection, const char *path, AFCIteratorRef *iterator);
|
||||||
|
typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **value);
|
||||||
|
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
||||||
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
||||||
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
||||||
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
||||||
@ -137,6 +141,9 @@ typedef struct {
|
|||||||
AFCDirectoryOpenFn AFCDirectoryOpen;
|
AFCDirectoryOpenFn AFCDirectoryOpen;
|
||||||
AFCDirectoryReadFn AFCDirectoryRead;
|
AFCDirectoryReadFn AFCDirectoryRead;
|
||||||
AFCDirectoryCloseFn AFCDirectoryClose;
|
AFCDirectoryCloseFn AFCDirectoryClose;
|
||||||
|
AFCFileInfoOpenFn AFCFileInfoOpen;
|
||||||
|
AFCKeyValueReadFn AFCKeyValueRead;
|
||||||
|
AFCKeyValueCloseFn AFCKeyValueClose;
|
||||||
AFCFileRefOpenFn AFCFileRefOpen;
|
AFCFileRefOpenFn AFCFileRefOpen;
|
||||||
AFCFileRefReadFn AFCFileRefRead;
|
AFCFileRefReadFn AFCFileRefRead;
|
||||||
AFCFileRefCloseFn AFCFileRefClose;
|
AFCFileRefCloseFn AFCFileRefClose;
|
||||||
@ -209,6 +216,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
|||||||
functions->AFCDirectoryOpen = (AFCDirectoryOpenFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryOpen");
|
functions->AFCDirectoryOpen = (AFCDirectoryOpenFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryOpen");
|
||||||
functions->AFCDirectoryRead = (AFCDirectoryReadFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryRead");
|
functions->AFCDirectoryRead = (AFCDirectoryReadFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryRead");
|
||||||
functions->AFCDirectoryClose = (AFCDirectoryCloseFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryClose");
|
functions->AFCDirectoryClose = (AFCDirectoryCloseFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryClose");
|
||||||
|
functions->AFCFileInfoOpen = (AFCFileInfoOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileInfoOpen");
|
||||||
|
functions->AFCKeyValueRead = (AFCKeyValueReadFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueRead");
|
||||||
|
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
||||||
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
||||||
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
||||||
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
||||||
@ -241,6 +251,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
|||||||
functions->AFCDirectoryOpen == NULL ||
|
functions->AFCDirectoryOpen == NULL ||
|
||||||
functions->AFCDirectoryRead == NULL ||
|
functions->AFCDirectoryRead == NULL ||
|
||||||
functions->AFCDirectoryClose == NULL ||
|
functions->AFCDirectoryClose == NULL ||
|
||||||
|
functions->AFCFileInfoOpen == NULL ||
|
||||||
|
functions->AFCKeyValueRead == NULL ||
|
||||||
|
functions->AFCKeyValueClose == NULL ||
|
||||||
functions->AFCFileRefOpen == NULL ||
|
functions->AFCFileRefOpen == NULL ||
|
||||||
functions->AFCFileRefRead == NULL ||
|
functions->AFCFileRefRead == NULL ||
|
||||||
functions->AFCFileRefClose == NULL) {
|
functions->AFCFileRefClose == NULL) {
|
||||||
@ -291,7 +304,7 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 2.0, false);
|
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false);
|
||||||
functions->AMDeviceNotificationUnsubscribe(subscription);
|
functions->AMDeviceNotificationUnsubscribe(subscription);
|
||||||
|
|
||||||
if (context.device == NULL && error != NULL) {
|
if (context.device == NULL && error != NULL) {
|
||||||
@ -570,6 +583,166 @@ static int WMMReadAFCDirectory(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NSDictionary<NSString *, NSString *> * _Nullable WMMCopyAFCFileInfo(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *path,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
AFCIteratorRef iterator = NULL;
|
||||||
|
const int openStatus = functions->AFCFileInfoOpen(
|
||||||
|
afcConnection,
|
||||||
|
path.fileSystemRepresentation,
|
||||||
|
&iterator
|
||||||
|
);
|
||||||
|
if (openStatus != 0 || iterator == NULL) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(openStatus, [NSString stringWithFormat:@"AFCFileInfoOpen failed for %@ (%d).", path, openStatus]);
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary<NSString *, NSString *> *info = [NSMutableDictionary dictionary];
|
||||||
|
while (true) {
|
||||||
|
char *key = NULL;
|
||||||
|
char *value = NULL;
|
||||||
|
const int readStatus = functions->AFCKeyValueRead(iterator, &key, &value);
|
||||||
|
if (readStatus != 0) {
|
||||||
|
functions->AFCKeyValueClose(iterator);
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(readStatus, [NSString stringWithFormat:@"AFCKeyValueRead failed for %@ (%d).", path, readStatus]);
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key == NULL || value == NULL) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *keyString = [NSString stringWithUTF8String:key];
|
||||||
|
NSString *valueString = [NSString stringWithUTF8String:value];
|
||||||
|
if (keyString.length > 0 && valueString.length > 0) {
|
||||||
|
info[keyString] = valueString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions->AFCKeyValueClose(iterator);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned long long WMMParseUnsignedLongLong(NSString *value) {
|
||||||
|
if (value.length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSScanner *hexScanner = [NSScanner scannerWithString:value];
|
||||||
|
unsigned long long hexValue = 0;
|
||||||
|
if (([value hasPrefix:@"0x"] || [value hasPrefix:@"0X"])
|
||||||
|
&& [hexScanner scanString:@"0x" intoString:nil]
|
||||||
|
&& [hexScanner scanHexLongLong:&hexValue]) {
|
||||||
|
return hexValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtoull(value.UTF8String, NULL, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDate * _Nullable WMMDateFromAFCTimestampString(NSString *value) {
|
||||||
|
unsigned long long rawValue = WMMParseUnsignedLongLong(value);
|
||||||
|
if (rawValue == 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSTimeInterval seconds;
|
||||||
|
if (rawValue > 10000000000000000ULL) {
|
||||||
|
seconds = (NSTimeInterval)rawValue / 1000000000.0;
|
||||||
|
} else if (rawValue > 10000000000000ULL) {
|
||||||
|
seconds = (NSTimeInterval)rawValue / 1000000.0;
|
||||||
|
} else if (rawValue > 10000000000ULL) {
|
||||||
|
seconds = (NSTimeInterval)rawValue / 1000.0;
|
||||||
|
} else {
|
||||||
|
seconds = (NSTimeInterval)rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [NSDate dateWithTimeIntervalSince1970:seconds];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDate * _Nullable WMMModificationDateFromAFCInfo(NSDictionary<NSString *, NSString *> *info) {
|
||||||
|
NSString *candidate = info[@"st_mtime"] ?: info[@"st_birthtime"];
|
||||||
|
if (candidate.length == 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WMMDateFromAFCTimestampString(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long long WMMFileSizeFromAFCInfo(NSDictionary<NSString *, NSString *> *info) {
|
||||||
|
NSString *candidate = info[@"st_size"];
|
||||||
|
if (candidate.length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long long parsed = WMMParseUnsignedLongLong(candidate);
|
||||||
|
if (parsed > LLONG_MAX) {
|
||||||
|
return LLONG_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (long long)parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary<NSString *, id> * _Nullable WMMCopyAFCTreeMetrics(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *remotePath,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
NSDictionary<NSString *, NSString *> *info = WMMCopyAFCFileInfo(functions, afcConnection, remotePath, error);
|
||||||
|
if (info == nil) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDate *latestModificationDate = WMMModificationDateFromAFCInfo(info);
|
||||||
|
NSMutableArray<NSString *> *entries = nil;
|
||||||
|
const int directoryStatus = WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries);
|
||||||
|
if (directoryStatus != 0) {
|
||||||
|
return @{
|
||||||
|
@"sizeBytes": @(WMMFileSizeFromAFCInfo(info)),
|
||||||
|
@"modifiedDate": latestModificationDate ?: [NSNull null]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
long long totalSize = 0;
|
||||||
|
for (NSString *entry in entries) {
|
||||||
|
if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *childRemotePath = [remotePath hasSuffix:@"/"]
|
||||||
|
? [remotePath stringByAppendingString:entry]
|
||||||
|
: [remotePath stringByAppendingPathComponent:entry];
|
||||||
|
NSDictionary<NSString *, id> *childMetrics = WMMCopyAFCTreeMetrics(
|
||||||
|
functions,
|
||||||
|
afcConnection,
|
||||||
|
childRemotePath,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
if (childMetrics == nil) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize += [childMetrics[@"sizeBytes"] longLongValue];
|
||||||
|
NSDate *childModifiedDate = childMetrics[@"modifiedDate"];
|
||||||
|
if ([childModifiedDate isKindOfClass:[NSDate class]]
|
||||||
|
&& (latestModificationDate == nil || [childModifiedDate compare:latestModificationDate] == NSOrderedDescending)) {
|
||||||
|
latestModificationDate = childModifiedDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"sizeBytes": @(totalSize),
|
||||||
|
@"modifiedDate": latestModificationDate ?: [NSNull null]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static BOOL WMMCopyAFCFileToLocalURL(
|
static BOOL WMMCopyAFCFileToLocalURL(
|
||||||
WMMMobileDeviceFunctions *functions,
|
WMMMobileDeviceFunctions *functions,
|
||||||
AFCConnectionRef afcConnection,
|
AFCConnectionRef afcConnection,
|
||||||
@ -596,6 +769,14 @@ static BOOL WMMCopyAFCFileToLocalURL(
|
|||||||
|
|
||||||
NSFileHandle *handle = [NSFileHandle fileHandleForWritingToURL:localFileURL error:error];
|
NSFileHandle *handle = [NSFileHandle fileHandleForWritingToURL:localFileURL error:error];
|
||||||
if (handle == nil) {
|
if (handle == nil) {
|
||||||
|
if (error != NULL && *error != nil) {
|
||||||
|
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||||
|
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to open local file %@ for remote AFC path %@: %@",
|
||||||
|
localFileURL.path,
|
||||||
|
remotePath,
|
||||||
|
(*error).localizedDescription]
|
||||||
|
}];
|
||||||
|
}
|
||||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
@ -624,6 +805,14 @@ static BOOL WMMCopyAFCFileToLocalURL(
|
|||||||
|
|
||||||
NSData *chunk = [NSData dataWithBytes:buffer.bytes length:bytesToRead];
|
NSData *chunk = [NSData dataWithBytes:buffer.bytes length:bytesToRead];
|
||||||
if (![handle writeData:chunk error:error]) {
|
if (![handle writeData:chunk error:error]) {
|
||||||
|
if (error != NULL && *error != nil) {
|
||||||
|
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||||
|
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed writing local file %@ for remote AFC path %@: %@",
|
||||||
|
localFileURL.path,
|
||||||
|
remotePath,
|
||||||
|
(*error).localizedDescription]
|
||||||
|
}];
|
||||||
|
}
|
||||||
success = NO;
|
success = NO;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -646,6 +835,14 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
|||||||
if (directoryStatus == 0) {
|
if (directoryStatus == 0) {
|
||||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||||
if (![fileManager createDirectoryAtURL:localURL withIntermediateDirectories:YES attributes:nil error:error]) {
|
if (![fileManager createDirectoryAtURL:localURL withIntermediateDirectories:YES attributes:nil error:error]) {
|
||||||
|
if (error != NULL && *error != nil) {
|
||||||
|
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||||
|
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to create local directory %@ for remote AFC path %@: %@",
|
||||||
|
localURL.path,
|
||||||
|
remotePath,
|
||||||
|
(*error).localizedDescription]
|
||||||
|
}];
|
||||||
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -660,8 +857,16 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
|||||||
} else {
|
} else {
|
||||||
childRemotePath = [childRemotePath stringByAppendingPathComponent:entry];
|
childRemotePath = [childRemotePath stringByAppendingPathComponent:entry];
|
||||||
}
|
}
|
||||||
NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry isDirectory:YES];
|
NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry];
|
||||||
if (!WMMCopyAFCTreeToLocalURL(functions, afcConnection, childRemotePath, childLocalURL, error)) {
|
if (!WMMCopyAFCTreeToLocalURL(functions, afcConnection, childRemotePath, childLocalURL, error)) {
|
||||||
|
if (error != NULL && *error != nil) {
|
||||||
|
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||||
|
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed copying remote AFC path %@ into %@: %@",
|
||||||
|
childRemotePath,
|
||||||
|
childLocalURL.path,
|
||||||
|
(*error).localizedDescription]
|
||||||
|
}];
|
||||||
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -672,6 +877,296 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
|||||||
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
||||||
|
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
||||||
|
if (![normalizedPath hasPrefix:@"/"]) {
|
||||||
|
normalizedPath = [@"/" stringByAppendingString:normalizedPath];
|
||||||
|
}
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSData * _Nullable WMMCopyAFCFileData(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *remotePath,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
AFCFileDescriptorRef fileDescriptor = NULL;
|
||||||
|
const int openStatus = functions->AFCFileRefOpen(
|
||||||
|
afcConnection,
|
||||||
|
remotePath.fileSystemRepresentation,
|
||||||
|
1,
|
||||||
|
&fileDescriptor
|
||||||
|
);
|
||||||
|
if (openStatus != 0 || fileDescriptor == NULL) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(openStatus, [NSString stringWithFormat:@"AFCFileRefOpen failed for %@ (%d).", remotePath, openStatus]);
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableData *data = [NSMutableData data];
|
||||||
|
NSMutableData *buffer = [NSMutableData dataWithLength:64 * 1024];
|
||||||
|
while (true) {
|
||||||
|
size_t bytesToRead = buffer.length;
|
||||||
|
const int readStatus = functions->AFCFileRefRead(
|
||||||
|
afcConnection,
|
||||||
|
fileDescriptor,
|
||||||
|
buffer.mutableBytes,
|
||||||
|
&bytesToRead
|
||||||
|
);
|
||||||
|
if (readStatus != 0) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(readStatus, [NSString stringWithFormat:@"AFCFileRefRead failed for %@ (%d).", remotePath, readStatus]);
|
||||||
|
}
|
||||||
|
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytesToRead == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data appendBytes:buffer.bytes length:bytesToRead];
|
||||||
|
}
|
||||||
|
|
||||||
|
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BOOL WMMEntryArrayContainsName(NSArray<NSString *> *entries, NSString *candidate) {
|
||||||
|
for (NSString *entry in entries) {
|
||||||
|
if ([entry isEqualToString:candidate]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString * _Nullable WMMReadUTF8TextFile(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *remotePath
|
||||||
|
) {
|
||||||
|
NSData *data = WMMCopyAFCFileData(functions, afcConnection, remotePath, NULL);
|
||||||
|
if (data == nil) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||||
|
return [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary<NSString *, id> * _Nullable WMMReadManifestHeader(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *remotePath
|
||||||
|
) {
|
||||||
|
NSData *data = WMMCopyAFCFileData(functions, afcConnection, remotePath, NULL);
|
||||||
|
if (data == nil) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||||
|
if (![jsonObject isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *header = jsonObject[@"header"];
|
||||||
|
if (![header isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString * _Nullable WMMVersionStringFromValue(id value) {
|
||||||
|
if ([value isKindOfClass:[NSString class]]) {
|
||||||
|
NSString *stringValue = [(NSString *)value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
return stringValue.length > 0 ? stringValue : nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([value isKindOfClass:[NSArray class]]) {
|
||||||
|
NSMutableArray<NSString *> *components = [NSMutableArray array];
|
||||||
|
for (id component in (NSArray *)value) {
|
||||||
|
if ([component isKindOfClass:[NSNumber class]]) {
|
||||||
|
[components addObject:[(NSNumber *)component stringValue]];
|
||||||
|
} else if ([component isKindOfClass:[NSString class]]) {
|
||||||
|
[components addObject:(NSString *)component];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return components.count > 0 ? [components componentsJoinedByString:@"."] : nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BOOL WMMIsCandidateItem(NSString *contentType, NSArray<NSString *> *entries) {
|
||||||
|
if ([contentType isEqualToString:@"World"]) {
|
||||||
|
return WMMEntryArrayContainsName(entries, @"level.dat")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"db")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"levelname.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
return WMMEntryArrayContainsName(entries, @"manifest.json")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.png")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpeg")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *contentType,
|
||||||
|
NSString *collectionFolderName,
|
||||||
|
NSString *itemRemotePath,
|
||||||
|
NSString *itemRelativePath,
|
||||||
|
NSString *folderName,
|
||||||
|
NSArray<NSString *> *entries
|
||||||
|
) {
|
||||||
|
NSMutableDictionary<NSString *, id> *summary = [@{
|
||||||
|
@"contentType": contentType,
|
||||||
|
@"collectionFolderName": collectionFolderName,
|
||||||
|
@"relativePath": itemRelativePath,
|
||||||
|
@"folderName": folderName
|
||||||
|
} mutableCopy];
|
||||||
|
|
||||||
|
NSString *displayName = folderName;
|
||||||
|
if ([contentType isEqualToString:@"World"]) {
|
||||||
|
NSString *levelName = WMMReadUTF8TextFile(
|
||||||
|
functions,
|
||||||
|
afcConnection,
|
||||||
|
[itemRemotePath stringByAppendingPathComponent:@"levelname.txt"]
|
||||||
|
);
|
||||||
|
if (levelName.length > 0) {
|
||||||
|
displayName = levelName;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NSDictionary<NSString *, id> *header = WMMReadManifestHeader(
|
||||||
|
functions,
|
||||||
|
afcConnection,
|
||||||
|
[itemRemotePath stringByAppendingPathComponent:@"manifest.json"]
|
||||||
|
);
|
||||||
|
NSString *manifestName = [header[@"name"] isKindOfClass:[NSString class]] ? header[@"name"] : nil;
|
||||||
|
if (manifestName.length > 0) {
|
||||||
|
displayName = manifestName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([header[@"uuid"] isKindOfClass:[NSString class]]) {
|
||||||
|
summary[@"packUUID"] = [header[@"uuid"] lowercaseString];
|
||||||
|
}
|
||||||
|
NSString *version = WMMVersionStringFromValue(header[@"version"]);
|
||||||
|
if (version.length > 0) {
|
||||||
|
summary[@"packVersion"] = version;
|
||||||
|
}
|
||||||
|
NSString *minimumEngineVersion = WMMVersionStringFromValue(header[@"min_engine_version"]);
|
||||||
|
if (minimumEngineVersion.length > 0) {
|
||||||
|
summary[@"minimumEngineVersion"] = minimumEngineVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary[@"displayName"] = displayName;
|
||||||
|
summary[@"hasIcon"] = @(
|
||||||
|
WMMEntryArrayContainsName(entries, @"world_icon.png")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"world_icon.jpeg")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"world_icon.jpg")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.png")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpeg")
|
||||||
|
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg")
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WMMAppendCollectionSummaries(
|
||||||
|
WMMMobileDeviceFunctions *functions,
|
||||||
|
AFCConnectionRef afcConnection,
|
||||||
|
NSString *rootRemotePath,
|
||||||
|
NSString *collectionFolderName,
|
||||||
|
NSString *contentType,
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *results
|
||||||
|
) {
|
||||||
|
NSString *collectionRemotePath = [rootRemotePath stringByAppendingPathComponent:collectionFolderName];
|
||||||
|
NSMutableArray<NSString *> *itemFolderNames = nil;
|
||||||
|
if (WMMReadAFCDirectory(functions, afcConnection, collectionRemotePath, &itemFolderNames) != 0 || itemFolderNames == nil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *itemFolderName in itemFolderNames) {
|
||||||
|
if ([itemFolderName isEqualToString:@"."] || [itemFolderName isEqualToString:@".."]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *itemRemotePath = [collectionRemotePath stringByAppendingPathComponent:itemFolderName];
|
||||||
|
NSMutableArray<NSString *> *itemEntries = nil;
|
||||||
|
if (WMMReadAFCDirectory(functions, afcConnection, itemRemotePath, &itemEntries) != 0 || itemEntries == nil) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WMMIsCandidateItem(contentType, itemEntries)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName];
|
||||||
|
[results addObject:WMMBuildMinecraftItemSummary(
|
||||||
|
functions,
|
||||||
|
afcConnection,
|
||||||
|
contentType,
|
||||||
|
collectionFolderName,
|
||||||
|
itemRemotePath,
|
||||||
|
itemRelativePath,
|
||||||
|
itemFolderName,
|
||||||
|
itemEntries
|
||||||
|
)];
|
||||||
|
|
||||||
|
if (![contentType isEqualToString:@"World"]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray<NSDictionary<NSString *, NSString *> *> *embeddedCollections = @[
|
||||||
|
@{ @"folder": @"behavior_packs", @"type": @"Behavior Pack" },
|
||||||
|
@{ @"folder": @"resource_packs", @"type": @"Resource Pack" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (NSDictionary<NSString *, NSString *> *embeddedCollection in embeddedCollections) {
|
||||||
|
NSString *embeddedFolder = embeddedCollection[@"folder"];
|
||||||
|
NSString *embeddedType = embeddedCollection[@"type"];
|
||||||
|
NSString *embeddedCollectionPath = [itemRemotePath stringByAppendingPathComponent:embeddedFolder];
|
||||||
|
NSMutableArray<NSString *> *embeddedFolderNames = nil;
|
||||||
|
if (WMMReadAFCDirectory(functions, afcConnection, embeddedCollectionPath, &embeddedFolderNames) != 0 || embeddedFolderNames == nil) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *embeddedFolderName in embeddedFolderNames) {
|
||||||
|
if ([embeddedFolderName isEqualToString:@"."] || [embeddedFolderName isEqualToString:@".."]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *embeddedItemPath = [embeddedCollectionPath stringByAppendingPathComponent:embeddedFolderName];
|
||||||
|
NSMutableArray<NSString *> *embeddedEntries = nil;
|
||||||
|
if (WMMReadAFCDirectory(functions, afcConnection, embeddedItemPath, &embeddedEntries) != 0 || embeddedEntries == nil) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WMMIsCandidateItem(embeddedType, embeddedEntries)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]];
|
||||||
|
[results addObject:WMMBuildMinecraftItemSummary(
|
||||||
|
functions,
|
||||||
|
afcConnection,
|
||||||
|
embeddedType,
|
||||||
|
embeddedFolder,
|
||||||
|
embeddedItemPath,
|
||||||
|
embeddedRelativePath,
|
||||||
|
embeddedFolderName,
|
||||||
|
embeddedEntries
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NSDictionary<NSString *, id> * _Nullable
|
NSDictionary<NSString *, id> * _Nullable
|
||||||
WMMCopyFirstConnectedDeviceSummary(NSError **error) {
|
WMMCopyFirstConnectedDeviceSummary(NSError **error) {
|
||||||
WMMMobileDeviceFunctions functions;
|
WMMMobileDeviceFunctions functions;
|
||||||
@ -996,6 +1491,207 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSDictionary<NSString *, id> * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
if (bundleIdentifier.length == 0) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(16, @"A bundle identifier is required.");
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
WMMMobileDeviceFunctions functions;
|
||||||
|
if (!WMMLoadFunctions(&functions, error)) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, 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) {
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *normalizedRootPath = WMMNormalizedAFCPath(relativePath);
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *items = [NSMutableArray array];
|
||||||
|
NSArray<NSDictionary<NSString *, NSString *> *> *collections = @[
|
||||||
|
@{ @"folder": @"minecraftWorlds", @"type": @"World" },
|
||||||
|
@{ @"folder": @"behavior_packs", @"type": @"Behavior Pack" },
|
||||||
|
@{ @"folder": @"resource_packs", @"type": @"Resource Pack" },
|
||||||
|
@{ @"folder": @"skin_packs", @"type": @"Skin Pack" },
|
||||||
|
@{ @"folder": @"world_templates", @"type": @"World Template" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (NSDictionary<NSString *, NSString *> *collection in collections) {
|
||||||
|
WMMAppendCollectionSummaries(
|
||||||
|
&functions,
|
||||||
|
afcConnection,
|
||||||
|
normalizedRootPath,
|
||||||
|
collection[@"folder"],
|
||||||
|
collection[@"type"],
|
||||||
|
items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
functions.AFCConnectionClose(afcConnection);
|
||||||
|
if (backingServiceConnection != NULL) {
|
||||||
|
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||||
|
}
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"bundleIdentifier": bundleIdentifier,
|
||||||
|
@"path": normalizedRootPath,
|
||||||
|
@"items": items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceAppFileData(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
if (bundleIdentifier.length == 0) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(17, @"A bundle identifier is required.");
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
WMMMobileDeviceFunctions functions;
|
||||||
|
if (!WMMLoadFunctions(&functions, error)) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, 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) {
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
|
||||||
|
NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error);
|
||||||
|
|
||||||
|
functions.AFCConnectionClose(afcConnection);
|
||||||
|
if (backingServiceConnection != NULL) {
|
||||||
|
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||||
|
}
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary<NSString *, id> * _Nullable
|
||||||
|
WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||||
|
NSString *bundleIdentifier,
|
||||||
|
NSString *relativePath,
|
||||||
|
NSError **error
|
||||||
|
) {
|
||||||
|
if (bundleIdentifier.length == 0) {
|
||||||
|
if (error != NULL) {
|
||||||
|
*error = WMMMakeError(18, @"A bundle identifier is required.");
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
WMMMobileDeviceFunctions functions;
|
||||||
|
if (!WMMLoadFunctions(&functions, error)) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, 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) {
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
|
||||||
|
NSDictionary<NSString *, id> *metrics = WMMCopyAFCTreeMetrics(
|
||||||
|
&functions,
|
||||||
|
afcConnection,
|
||||||
|
normalizedPath,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
functions.AFCConnectionClose(afcConnection);
|
||||||
|
if (backingServiceConnection != NULL) {
|
||||||
|
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||||
|
}
|
||||||
|
WMMDisconnectDevice(&functions, device, YES);
|
||||||
|
functions.AMDeviceRelease(device);
|
||||||
|
|
||||||
|
if (metrics == nil) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"bundleIdentifier": bundleIdentifier,
|
||||||
|
@"path": normalizedPath,
|
||||||
|
@"sizeBytes": metrics[@"sizeBytes"] ?: @0,
|
||||||
|
@"modifiedDate": metrics[@"modifiedDate"] ?: [NSNull null]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
NSDictionary<NSString *, id> * _Nullable
|
NSDictionary<NSString *, id> * _Nullable
|
||||||
WMMCopyFirstConnectedDeviceApplicationDetails(
|
WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||||
NSString *bundleIdentifier,
|
NSString *bundleIdentifier,
|
||||||
|
|||||||
@ -8,13 +8,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||||
private let mirrorRootURL: URL
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "connected-device.apple-mobile-device"
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init() {}
|
||||||
mirrorRootURL: URL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("WorldManagerConnectedDevices", isDirectory: true)
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
) {
|
_ = source
|
||||||
self.mirrorRootURL = mirrorRootURL
|
return SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: accessorIdentifier,
|
||||||
|
kind: .connectedDevice,
|
||||||
|
capabilities: .connectedDevice,
|
||||||
|
refreshStrategy: .staged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let devices = try await listConnectedDevices()
|
||||||
|
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
|
||||||
|
return .disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
switch device.trustState {
|
||||||
|
case .trusted:
|
||||||
|
return .available
|
||||||
|
case .locked, .untrusted:
|
||||||
|
return .limited
|
||||||
|
case .unavailable:
|
||||||
|
return .disconnected
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return .disconnected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
|
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
|
||||||
@ -35,7 +64,11 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
let applications = try await AppleMobileDeviceAccess.listApplications()
|
let applications = try await AppleMobileDeviceAccess.listApplications()
|
||||||
|
|
||||||
return applications
|
return applications
|
||||||
.filter { $0.fileSharingEnabled }
|
.filter { application in
|
||||||
|
application.fileSharingEnabled
|
||||||
|
|| application.supportsOpeningDocumentsInPlace
|
||||||
|
|| application.bundleIdentifier == "com.mojang.minecraftpe"
|
||||||
|
}
|
||||||
.map { application in
|
.map { application in
|
||||||
DeviceAppContainer(
|
DeviceAppContainer(
|
||||||
deviceUDID: device.udid,
|
deviceUDID: device.udid,
|
||||||
@ -58,7 +91,10 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws -> [MinecraftContentItem] {
|
||||||
guard case .connectedDevice(_, let container) = source.origin else {
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
throw SourceAccessError.accessFailed(
|
throw SourceAccessError.accessFailed(
|
||||||
reason: "The selected source is not backed by a connected mobile device."
|
reason: "The selected source is not backed by a connected mobile device."
|
||||||
@ -72,38 +108,421 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
|
||||||
let mirrorURL = mirrorRootURL
|
bundleIdentifier: container.appID,
|
||||||
.appendingPathComponent(container.deviceUDID, isDirectory: true)
|
relativePath: requestedSubpath
|
||||||
.appendingPathComponent(container.appID.replacingOccurrences(of: ".", with: "_"), isDirectory: true)
|
)
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
||||||
|
|
||||||
do {
|
let items = summaries.compactMap { summary in
|
||||||
try fileManager.createDirectory(at: mirrorURL, withIntermediateDirectories: true)
|
makeItem(from: summary, source: source)
|
||||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
|
||||||
bundleIdentifier: container.appID,
|
|
||||||
relativePath: requestedSubpath,
|
|
||||||
destinationDirectoryURL: mirrorURL
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
try? fileManager.removeItem(at: mirrorURL)
|
|
||||||
throw SourceAccessError.accessFailed(reason: error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return PreparedScanRoot(
|
for item in items {
|
||||||
sourceID: source.id,
|
onDiscovered(item)
|
||||||
rootURL: mirrorURL,
|
}
|
||||||
mountPointURL: mirrorURL,
|
|
||||||
cleanupBehavior: .deleteTemporaryDirectory
|
return items
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
guard case .deleteTemporaryDirectory = preparedScanRoot.cleanupBehavior,
|
var enrichedItem = item
|
||||||
let mountPointURL = preparedScanRoot.mountPointURL else {
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
|
enrichedItem.metadataLoaded = true
|
||||||
|
return enrichedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedItem.iconURL = await loadRemoteIcon(for: item, source: source, container: container)
|
||||||
|
enrichedItem.modifiedDate = nil
|
||||||
|
|
||||||
|
if item.contentType == .world {
|
||||||
|
if let levelDatPath = remoteItemPath(for: item, in: source, appending: "level.dat"),
|
||||||
|
let levelDatData = try? await AppleMobileDeviceAccess.fileData(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: levelDatPath
|
||||||
|
) {
|
||||||
|
enrichedItem.worldMetadata = BedrockLevelMetadataDecoder.decode(fromLevelDatData: levelDatData)
|
||||||
|
enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedItem.packReferences = await loadWorldPackReferences(for: item, source: source, container: container)
|
||||||
|
} else {
|
||||||
|
enrichedItem.lastPlayedDate = nil
|
||||||
|
enrichedItem.packReferences = []
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedItem.metadataLoaded = true
|
||||||
|
enrichedItem.sizeLoaded = false
|
||||||
|
return enrichedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
var sizedItem = item
|
||||||
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
|
sizedItem.sizeLoaded = true
|
||||||
|
return sizedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
if let remoteItemPath = remoteItemPath(for: item, in: source),
|
||||||
|
let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remoteItemPath
|
||||||
|
) {
|
||||||
|
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 []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteFolderPath = remoteItemPath(for: item, in: source) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = try await AppleMobileDeviceAccess.listDirectory(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remoteFolderPath
|
||||||
|
)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map { entry in
|
||||||
|
let isDirectory = !NSString(string: entry).pathExtension.isEmpty ? false : true
|
||||||
|
return DirectoryPreviewEntry(name: entry, isDirectory: isDirectory)
|
||||||
|
}
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
if lhs.isDirectory != rhs.isDirectory {
|
||||||
|
return lhs.isDirectory && !rhs.isDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
|
return item.folderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteItemPath = remoteItemPath(for: item, in: source) else {
|
||||||
|
throw SourceAccessError.accessFailed(reason: "Could not resolve the device path for this item.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("WMMConnectedDeviceReveal", isDirectory: true)
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||||
|
do {
|
||||||
|
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remoteItemPath,
|
||||||
|
destinationDirectoryURL: destinationURL
|
||||||
|
)
|
||||||
|
return destinationURL
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: destinationURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
|
guard source.origin.kind == .connectedDevice else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: mountPointURL)
|
try? ConnectedDeviceMirrorCache.purgeRootURL(for: source.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func makeItem(
|
||||||
|
from summary: AppleMobileMinecraftLibraryItemSummary,
|
||||||
|
source: MinecraftSource
|
||||||
|
) -> MinecraftContentItem? {
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
switch summary.contentType {
|
||||||
|
case MinecraftContentType.world.rawValue:
|
||||||
|
contentType = .world
|
||||||
|
case MinecraftContentType.behaviorPack.rawValue:
|
||||||
|
contentType = .behaviorPack
|
||||||
|
case MinecraftContentType.resourcePack.rawValue:
|
||||||
|
contentType = .resourcePack
|
||||||
|
case MinecraftContentType.skinPack.rawValue:
|
||||||
|
contentType = .skinPack
|
||||||
|
case MinecraftContentType.worldTemplate.rawValue:
|
||||||
|
contentType = .worldTemplate
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
|
||||||
|
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, isDirectory: true)
|
||||||
|
return MinecraftContentItem(
|
||||||
|
folderURL: folderURL,
|
||||||
|
folderName: summary.folderName,
|
||||||
|
contentType: contentType,
|
||||||
|
collectionRootURL: collectionRootURL,
|
||||||
|
displayName: summary.displayName,
|
||||||
|
iconURL: nil,
|
||||||
|
packUUID: summary.packUUID,
|
||||||
|
packVersion: summary.packVersion,
|
||||||
|
packMetadataDetails: PackMetadataDetails(minimumEngineVersion: summary.minimumEngineVersion),
|
||||||
|
metadataLoaded: false,
|
||||||
|
sizeLoaded: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func remoteItemPath(
|
||||||
|
for item: MinecraftContentItem,
|
||||||
|
in source: MinecraftSource,
|
||||||
|
appending childPath: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
guard case .connectedDevice(_, let container) = source.origin else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootPath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !rootPath.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "")
|
||||||
|
guard !relativeItemPath.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let basePath = appendPathComponents(
|
||||||
|
rootPath,
|
||||||
|
components: relativeItemPath.split(separator: "/").map(String.init)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let childPath, !childPath.isEmpty {
|
||||||
|
return NSString(string: basePath).appendingPathComponent(childPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func loadRemoteIcon(
|
||||||
|
for item: MinecraftContentItem,
|
||||||
|
source: MinecraftSource,
|
||||||
|
container: DeviceAppContainer
|
||||||
|
) async -> URL? {
|
||||||
|
let candidateNames: [String]
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidateName in candidateNames {
|
||||||
|
guard let remotePath = remoteItemPath(for: item, in: source, appending: candidateName) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let data = try? await AppleMobileDeviceAccess.fileData(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remotePath
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathExtension = NSString(string: candidateName).pathExtension
|
||||||
|
return await ImageCacheStore.shared.cachedImageURL(
|
||||||
|
forRemoteData: data,
|
||||||
|
cacheKey: "\(container.deviceUDID)::\(container.appID)::\(remotePath)",
|
||||||
|
pathExtension: pathExtension
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func appendPathComponents(_ root: String, components: [String]) -> String {
|
||||||
|
components.reduce(root) { partial, component in
|
||||||
|
NSString(string: partial).appendingPathComponent(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func loadWorldPackReferences(
|
||||||
|
for item: MinecraftContentItem,
|
||||||
|
source: MinecraftSource,
|
||||||
|
container: DeviceAppContainer
|
||||||
|
) async -> [ContentPackReference] {
|
||||||
|
var references: [ContentPackReference] = []
|
||||||
|
|
||||||
|
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
|
||||||
|
let behaviorData = try? await AppleMobileDeviceAccess.fileData(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: behaviorRefPath
|
||||||
|
) {
|
||||||
|
references.append(contentsOf: parsePackReferences(from: behaviorData, type: .behaviorPack))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
|
||||||
|
let resourceData = try? await AppleMobileDeviceAccess.fileData(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: resourceRefPath
|
||||||
|
) {
|
||||||
|
references.append(contentsOf: parsePackReferences(from: resourceData, type: .resourcePack))
|
||||||
|
}
|
||||||
|
|
||||||
|
references.append(contentsOf: await loadEmbeddedPackReferences(
|
||||||
|
for: item,
|
||||||
|
source: source,
|
||||||
|
container: container,
|
||||||
|
folderName: "behavior_packs",
|
||||||
|
type: .behaviorPack
|
||||||
|
))
|
||||||
|
references.append(contentsOf: await loadEmbeddedPackReferences(
|
||||||
|
for: item,
|
||||||
|
source: source,
|
||||||
|
container: container,
|
||||||
|
folderName: "resource_packs",
|
||||||
|
type: .resourcePack
|
||||||
|
))
|
||||||
|
|
||||||
|
return uniquePackReferences(references)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func loadEmbeddedPackReferences(
|
||||||
|
for item: MinecraftContentItem,
|
||||||
|
source: MinecraftSource,
|
||||||
|
container: DeviceAppContainer,
|
||||||
|
folderName: String,
|
||||||
|
type: MinecraftContentType
|
||||||
|
) async -> [ContentPackReference] {
|
||||||
|
guard let remoteFolderPath = remoteItemPath(for: item, in: source, appending: folderName) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: remoteFolderPath
|
||||||
|
) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var references: [ContentPackReference] = []
|
||||||
|
for childFolder in childFolders {
|
||||||
|
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
|
||||||
|
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
|
||||||
|
guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
|
||||||
|
bundleIdentifier: container.appID,
|
||||||
|
relativePath: manifestPath
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let metadata = parseManifestMetadata(from: manifestData, fallbackName: childFolder) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
references.append(
|
||||||
|
ContentPackReference(
|
||||||
|
name: metadata.name,
|
||||||
|
type: type,
|
||||||
|
iconURL: nil,
|
||||||
|
uuid: metadata.uuid,
|
||||||
|
version: metadata.version,
|
||||||
|
source: .embeddedInWorld
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func parsePackReferences(
|
||||||
|
from data: Data,
|
||||||
|
type: MinecraftContentType
|
||||||
|
) -> [ContentPackReference] {
|
||||||
|
guard let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonObject.map { entry in
|
||||||
|
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||||
|
let version = versionString(from: entry["version"])
|
||||||
|
return ContentPackReference(
|
||||||
|
name: uuid ?? "Referenced Pack",
|
||||||
|
type: type,
|
||||||
|
iconURL: nil,
|
||||||
|
uuid: uuid,
|
||||||
|
version: version,
|
||||||
|
source: .referencedByWorld
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func parseManifestMetadata(
|
||||||
|
from data: Data,
|
||||||
|
fallbackName: String
|
||||||
|
) -> (name: String, uuid: String?, version: String?, minimumEngineVersion: String?)? {
|
||||||
|
guard
|
||||||
|
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||||
|
let header = jsonObject["header"] as? [String: Any]
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
} ?? fallbackName
|
||||||
|
|
||||||
|
return (
|
||||||
|
name: name,
|
||||||
|
uuid: (header["uuid"] as? String)?.lowercased(),
|
||||||
|
version: versionString(from: header["version"]),
|
||||||
|
minimumEngineVersion: versionString(from: header["min_engine_version"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func versionString(from value: Any?) -> String? {
|
||||||
|
if let versionString = value as? String, !versionString.isEmpty {
|
||||||
|
return versionString
|
||||||
|
}
|
||||||
|
|
||||||
|
if let versionArray = value as? [Any] {
|
||||||
|
let components = versionArray.compactMap { component -> String? in
|
||||||
|
if let intComponent = component as? Int {
|
||||||
|
return String(intComponent)
|
||||||
|
}
|
||||||
|
if let stringComponent = component as? String {
|
||||||
|
return stringComponent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return components.isEmpty ? nil : components.joined(separator: ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var uniqueReferences: [ContentPackReference] = []
|
||||||
|
|
||||||
|
for reference in references {
|
||||||
|
let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""]
|
||||||
|
.joined(separator: "::")
|
||||||
|
guard seen.insert(dedupeKey).inserted else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uniqueReferences.append(reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueReferences.sorted { lhs, rhs in
|
||||||
|
if lhs.type != rhs.type {
|
||||||
|
return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending
|
||||||
|
}
|
||||||
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// ConnectedDeviceMirrorCache.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by OpenAI on 2026-05-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ConnectedDeviceMirrorCache {
|
||||||
|
nonisolated static func rootURL(for sourceID: URL, fileManager: FileManager = .default) -> URL {
|
||||||
|
let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
|
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
|
||||||
|
|
||||||
|
return applicationSupportURL
|
||||||
|
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
|
||||||
|
.appendingPathComponent("ConnectedDeviceCache", isDirectory: true)
|
||||||
|
.appendingPathComponent(sanitizedComponent(for: sourceID), isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func purgeRootURL(for sourceID: URL, fileManager: FileManager = .default) throws {
|
||||||
|
let rootURL = rootURL(for: sourceID, fileManager: fileManager)
|
||||||
|
guard fileManager.fileExists(atPath: rootURL.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.removeItem(at: rootURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func sanitizedComponent(for sourceID: URL) -> String {
|
||||||
|
let rawValue = sourceID.absoluteString
|
||||||
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
|
||||||
|
|
||||||
|
let pieces = rawValue.unicodeScalars.map { scalar -> String in
|
||||||
|
allowed.contains(scalar) ? String(scalar) : "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(pieces.joined().prefix(180))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,12 +15,18 @@ struct ConnectedDeviceSourceFactory: Sendable {
|
|||||||
container: DeviceAppContainer
|
container: DeviceAppContainer
|
||||||
) -> MinecraftSource {
|
) -> MinecraftSource {
|
||||||
let sourceID = makeSourceIdentifier(device: device, container: container)
|
let sourceID = makeSourceIdentifier(device: device, container: container)
|
||||||
let placeholderFolderURL = URL(fileURLWithPath: "/Volumes/\(sourceID.lastPathComponent)", isDirectory: true)
|
let cacheRootURL = ConnectedDeviceMirrorCache.rootURL(for: sourceID)
|
||||||
|
|
||||||
var source = MinecraftSource(
|
var source = MinecraftSource(
|
||||||
sourceID: sourceID,
|
sourceID: sourceID,
|
||||||
folderURL: placeholderFolderURL,
|
folderURL: cacheRootURL,
|
||||||
origin: .connectedDevice(device: device, container: container)
|
origin: .connectedDevice(device: device, container: container),
|
||||||
|
accessDescriptor: SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: AppleMobileDeviceSourceAccess().accessorIdentifier,
|
||||||
|
kind: .connectedDevice,
|
||||||
|
capabilities: .connectedDevice,
|
||||||
|
refreshStrategy: .staged
|
||||||
|
)
|
||||||
)
|
)
|
||||||
source.displayName = displayName(for: device, container: container)
|
source.displayName = displayName(for: device, container: container)
|
||||||
return source
|
return source
|
||||||
|
|||||||
@ -8,13 +8,71 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol SourceAccessMethod: Sendable {
|
protocol SourceAccessMethod: Sendable {
|
||||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot
|
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
||||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws -> [MinecraftContentItem]
|
||||||
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
||||||
|
nonisolated func loadSize(for item: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SourceAccessMethod {
|
extension SourceAccessMethod {
|
||||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
nonisolated var accessorIdentifier: SourceAccessorIdentifier {
|
||||||
_ = preparedScanRoot
|
String(reflecting: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: accessorIdentifier,
|
||||||
|
kind: source.origin.kind,
|
||||||
|
capabilities: source.origin.defaultCapabilities,
|
||||||
|
refreshStrategy: source.origin.defaultRefreshStrategy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
_ = source
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws -> [MinecraftContentItem] {
|
||||||
|
_ = source
|
||||||
|
_ = onDiscovered
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||||
|
_ = source
|
||||||
|
_ = item
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
_ = source
|
||||||
|
return item.folderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
|
_ = source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,29 +82,72 @@ protocol ConnectedDeviceSourceAccessMethod: SourceAccessMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SourceAccessCoordinator: SourceAccessMethod {
|
struct SourceAccessCoordinator: SourceAccessMethod {
|
||||||
private let localFolderAccess: SourceAccessMethod
|
private let accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod]
|
||||||
private let connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
|
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||||
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
||||||
) {
|
) {
|
||||||
self.localFolderAccess = localFolderAccess
|
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess])
|
||||||
self.connectedDeviceAccess = connectedDeviceAccess
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
nonisolated init(accessMethods: [any SourceAccessMethod]) {
|
||||||
switch source.origin {
|
var accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod] = [:]
|
||||||
case .localFolder:
|
for accessMethod in accessMethods {
|
||||||
return try await localFolderAccess.prepareScanRoot(for: source)
|
accessMethodsByIdentifier[accessMethod.accessorIdentifier] = accessMethod
|
||||||
case .connectedDevice:
|
|
||||||
return try await connectedDeviceAccess.prepareScanRoot(for: source)
|
|
||||||
}
|
}
|
||||||
|
self.accessMethodsByIdentifier = accessMethodsByIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
nonisolated private func accessMethod(for source: MinecraftSource) -> (any SourceAccessMethod) {
|
||||||
await localFolderAccess.releaseScanRoot(preparedScanRoot)
|
if let accessMethod = accessMethodsByIdentifier[source.accessDescriptor.accessorIdentifier] {
|
||||||
await connectedDeviceAccess.releaseScanRoot(preparedScanRoot)
|
return accessMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
if let accessMethod = accessMethodsByIdentifier[source.origin.defaultAccessorIdentifier] {
|
||||||
|
return accessMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
if let accessMethod = accessMethodsByIdentifier[LocalFolderSourceAccess().accessorIdentifier] {
|
||||||
|
return accessMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws -> [MinecraftContentItem] {
|
||||||
|
return try await accessMethod(for: source).discoverItems(for: source, onDiscovered: onDiscovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
accessMethod(for: source).accessDescriptor(for: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
return await accessMethod(for: source).availability(for: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
return await accessMethod(for: source).enrich(item, for: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
return await accessMethod(for: source).loadSize(for: item, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||||
|
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,46 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct LocalFolderSourceAccess: SourceAccessMethod {
|
struct LocalFolderSourceAccess: SourceAccessMethod {
|
||||||
|
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
|
||||||
|
|
||||||
nonisolated init() {}
|
nonisolated init() {}
|
||||||
|
|
||||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
_ = source
|
||||||
|
return SourceAccessDescriptor(
|
||||||
|
accessorIdentifier: accessorIdentifier,
|
||||||
|
kind: .localFolder,
|
||||||
|
capabilities: .localFolder,
|
||||||
|
refreshStrategy: .eagerFullScan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||||
|
let candidateURL: URL
|
||||||
|
if case .localFolder(let bookmarkData) = source.origin,
|
||||||
|
let bookmarkData {
|
||||||
|
var isStale = false
|
||||||
|
if let resolvedURL = try? URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [.withSecurityScope],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
) {
|
||||||
|
candidateURL = resolvedURL.standardizedFileURL
|
||||||
|
} else {
|
||||||
|
candidateURL = source.folderURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidateURL = source.folderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func discoverItems(
|
||||||
|
for source: MinecraftSource,
|
||||||
|
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||||
|
) async throws -> [MinecraftContentItem] {
|
||||||
guard case .localFolder(let bookmarkData) = source.origin else {
|
guard case .localFolder(let bookmarkData) = source.origin else {
|
||||||
throw SourceAccessError.accessFailed(
|
throw SourceAccessError.accessFailed(
|
||||||
reason: "No local-folder access method is configured for this source type."
|
reason: "No local-folder access method is configured for this source type."
|
||||||
@ -36,11 +73,51 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
|||||||
resolvedURL = source.folderURL
|
resolvedURL = source.folderURL
|
||||||
}
|
}
|
||||||
|
|
||||||
return PreparedScanRoot(
|
let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource()
|
||||||
sourceID: source.id,
|
defer {
|
||||||
rootURL: resolvedURL,
|
if accessedSecurityScope {
|
||||||
mountPointURL: nil,
|
resolvedURL.stopAccessingSecurityScopedResource()
|
||||||
cleanupBehavior: .none
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return await WorldScanner.enrich(item: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||||
|
_ = source
|
||||||
|
return WorldScanner.loadSize(for: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||||
|
_ = source
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let urls = try fileManager.contentsOfDirectory(
|
||||||
|
at: item.folderURL,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return urls
|
||||||
|
.map { url in
|
||||||
|
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||||
|
return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory)
|
||||||
|
}
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
if lhs.isDirectory != rhs.isDirectory {
|
||||||
|
return lhs.isDirectory && !rhs.isDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||||
|
_ = source
|
||||||
|
return item.folderURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user