From ab6661d66b090870b075f5cb6dbe79c080ffdfd3 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Tue, 26 May 2026 22:28:20 -0500 Subject: [PATCH] Refactor, support lightweight device scanning --- World Manager for Minecraft/ContentView.swift | 137 +++- .../ItemDetailColumnViews.swift | 4 + .../ItemListColumnViews.swift | 2 + .../Models/MinecraftSource.swift | 28 +- .../Models/SourceOrigin.swift | 42 +- .../Models/SourceRecord.swift | 61 ++ .../PreviewFixtures.swift | 7 +- .../Services/ContentPackageExporter.swift | 85 ++- .../Services/ImageCacheStore.swift | 27 + .../Services/SourceLibrary.swift | 437 +++++++++-- .../Services/SourcePersistenceStore.swift | 165 ++++- .../SidebarColumnViews.swift | 164 +++- .../AppleMobileDeviceAccess.swift | 168 ++++- .../AppleMobileDeviceBridge.h | 21 + .../AppleMobileDeviceBridge.m | 700 +++++++++++++++++- .../AppleMobileDeviceSourceAccess.swift | 485 +++++++++++- .../ConnectedDeviceMirrorCache.swift | 40 + .../ConnectedDeviceSourceFactory.swift | 12 +- .../Core/SourceAccessCoordinator.swift | 135 +++- .../LocalFolder/LocalFolderSourceAccess.swift | 89 ++- 20 files changed, 2559 insertions(+), 250 deletions(-) create mode 100644 World Manager for Minecraft/Models/SourceRecord.swift create mode 100644 World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceMirrorCache.swift diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 86a26f3..390b22e 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -19,6 +19,7 @@ struct ContentView: View { @State private var isPerformingItemAction = false @State private var isShowingDeviceSourceSheet = false @State private var sortMode: ItemSortMode = .name + @State private var directoryPreviewContents: [DirectoryPreviewEntry] = [] private let connectedDeviceAccess: AppleMobileDeviceSourceAccess private let deviceSourceFactory: ConnectedDeviceSourceFactory @@ -32,7 +33,8 @@ struct ContentView: View { wrappedValue: SourceLibrary( sourceAccessMethod: SourceAccessCoordinator( connectedDeviceAccess: connectedDeviceAccess - ) + ), + connectedDeviceAccessMethod: connectedDeviceAccess ) ) } @@ -40,11 +42,13 @@ struct ContentView: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SourcesSidebarView( - sources: library.sources, + localSources: library.localSources, + connectedDevices: library.connectedDevices, selection: $selectedSidebarSelection, footerState: library.sidebarFooterState, addSourceAction: pickFolder, addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, + addConnectedDeviceAction: addConnectedDeviceSource(from:), rescanSourceAction: { source in selectedSidebarSelection = .allContent(sourceID: source.id) selectedItemID = nil @@ -54,12 +58,19 @@ struct ContentView: View { removeSource(source.id) }, 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) } content: { ItemListColumnView( - isEmpty: library.sources.isEmpty, + isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isDropTargeted: $isDropTargeted, selectedItemID: $selectedItemID, searchText: $searchText, @@ -87,9 +98,9 @@ struct ContentView: View { worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [], isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false, - contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [], + contents: directoryPreviewContents, directoryPreviewLimit: directoryPreviewLimit, - isEmpty: library.sources.isEmpty, + isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isPerformingItemAction: isPerformingItemAction, exportTitle: currentSelectedItem.map(primaryActionTitle(for:)), exportAction: { @@ -126,7 +137,7 @@ struct ContentView: View { deviceDiscoveryService: connectedDeviceAccess, sourceFactory: deviceSourceFactory, 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) selectedItemID = nil isShowingDeviceSourceSheet = false @@ -141,8 +152,14 @@ struct ContentView: View { self.selectedItemID = nil } - .onChange(of: library.sources.map(\.id)) { _, sourceIDs in - syncSelection(with: sourceIDs) + .onChange(of: library.sources.map(\.id)) { _, _ in + 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? { guard let sourceID = selectedSidebarSelection?.sourceID else { - return library.sources.first + return library.visibleSources.first } return library.source(withID: sourceID) @@ -187,7 +204,7 @@ struct ContentView: View { return nil } - return library.sources + return library.visibleSources .flatMap(\.items) .first(where: { $0.id == selectedItemID }) } @@ -503,31 +520,22 @@ struct ContentView: View { return logicalPack.isSuspicious } - private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] { - let fileManager = FileManager.default - - guard let urls = try? fileManager.contentsOfDirectory( - at: item.folderURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { - return [] + private func refreshDirectoryPreviewContents() async { + guard let item = currentSelectedItem, let source = currentSource else { + await MainActor.run { + directoryPreviewContents = [] + } + return } - 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 - } + let contents = (try? await library.listContents(for: item, in: source)) ?? [] + guard !Task.isCancelled else { + return + } - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } - .prefix(directoryPreviewLimit) - .map { $0 } + await MainActor.run { + directoryPreviewContents = Array(contents.prefix(directoryPreviewLimit)) + } } private func pickFolder() { @@ -582,7 +590,7 @@ struct ContentView: View { } 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) 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]) { if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) { self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) } @@ -602,7 +621,7 @@ struct ContentView: View { } if let selectedItemID { - let itemStillExists = library.sources + let itemStillExists = library.visibleSources .flatMap(\.items) .contains(where: { $0.id == selectedItemID }) @@ -636,7 +655,11 @@ struct ContentView: View { Task { do { 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 await MainActor.run { @@ -668,7 +691,10 @@ struct ContentView: View { Task { do { let shareURL = try await Task.detached(priority: .userInitiated) { - try ContentPackageExporter.createArchiveFile(for: item, source: source) + try await ContentPackageExporter.createArchiveFile( + for: item, + source: source + ) }.value await MainActor.run { @@ -703,7 +729,42 @@ struct ContentView: View { } 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) { diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index 02ed903..03e1a2c 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -612,6 +612,10 @@ struct ItemDetailView: View { return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) } + if item.sizeLoaded { + return "Unavailable" + } + return item.metadataLoaded ? "Calculating..." : "Loading..." } diff --git a/World Manager for Minecraft/ItemListColumnViews.swift b/World Manager for Minecraft/ItemListColumnViews.swift index 0efdb6c..6f33cf5 100644 --- a/World Manager for Minecraft/ItemListColumnViews.swift +++ b/World Manager for Minecraft/ItemListColumnViews.swift @@ -169,6 +169,8 @@ private struct ContentRowView: View { let sizeText: String if let sizeBytes = item.sizeBytes { sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } else if item.sizeLoaded { + sizeText = "Size unavailable" } else if item.metadataLoaded { sizeText = "Calculating size..." } else { diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index f6d11de..93e5e7e 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -11,6 +11,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL var origin: MinecraftSourceOrigin + var accessDescriptor: SourceAccessDescriptor + var availability: SourceAvailability var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] @@ -31,12 +33,22 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { sourceID: URL? = nil, folderURL: URL, bookmarkData: Data? = nil, - origin: MinecraftSourceOrigin? = nil + origin: MinecraftSourceOrigin? = nil, + accessDescriptor: SourceAccessDescriptor? = nil, + availability: SourceAvailability = .unknown ) { let normalizedFolderURL = normalizedSourceURL(folderURL) + let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData) self.id = normalizedSourceURL(sourceID ?? 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.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] @@ -108,6 +120,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { .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 { switch item.contentType { case .world, .behaviorPack, .resourcePack: diff --git a/World Manager for Minecraft/Models/SourceOrigin.swift b/World Manager for Minecraft/Models/SourceOrigin.swift index 8f71b75..571df38 100644 --- a/World Manager for Minecraft/Models/SourceOrigin.swift +++ b/World Manager for Minecraft/Models/SourceOrigin.swift @@ -51,7 +51,16 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable { case localFolder(bookmarkData: Data?) 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 { case .localFolder: return .localFolder @@ -59,22 +68,27 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable { 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 { case localFolder 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 - } -} diff --git a/World Manager for Minecraft/Models/SourceRecord.swift b/World Manager for Minecraft/Models/SourceRecord.swift new file mode 100644 index 0000000..a346be5 --- /dev/null +++ b/World Manager for Minecraft/Models/SourceRecord.swift @@ -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? +} diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index 9cc90a1..7e9a438 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -290,15 +290,18 @@ struct SidebarColumnPreviewContainer: View { var body: some View { NavigationStack { SourcesSidebarView( - sources: PreviewFixtures.allSources, + localSources: PreviewFixtures.allSources, + connectedDevices: [], selection: $selection, footerState: PreviewFixtures.sidebarFooter, addSourceAction: {}, addDeviceSourceAction: {}, + addConnectedDeviceAction: { _ in }, rescanSourceAction: { _ in }, removeSourceAction: { _ in }, revealFooterURLAction: { _ in }, - filters: PreviewFixtures.sidebarFilters(for:) + filters: PreviewFixtures.sidebarFilters(for:), + matchedSource: { _ in nil } ) } } diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift index 276ebd2..2bcbd9c 100644 --- a/World Manager for Minecraft/Services/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift @@ -27,7 +27,7 @@ enum ContentPackageExporter { for item: MinecraftContentItem, source: MinecraftSource? = nil, destinationURL: URL? = nil - ) throws -> URL { + ) async throws -> URL { let fileManager = FileManager.default let archiveURL: URL @@ -41,7 +41,7 @@ enum ContentPackageExporter { try fileManager.removeItem(at: archiveURL) } - try createArchive(for: item, source: source, at: archiveURL) + try await createArchive(for: item, source: source, at: archiveURL) return archiveURL } @@ -61,9 +61,9 @@ enum ContentPackageExporter { for item: MinecraftContentItem, source: MinecraftSource?, at archiveURL: URL - ) throws { + ) async throws { 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 { try? fileManager.removeItem(at: stagingDirectoryURL) @@ -104,31 +104,40 @@ enum ContentPackageExporter { for item: MinecraftContentItem, source: MinecraftSource?, fileManager: FileManager - ) throws -> URL { + ) async throws -> URL { let stagingDirectoryURL = fileManager.temporaryDirectory .appendingPathComponent("MinecraftArchiveStaging", isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: 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 { - let contents = try fileManager.contentsOfDirectory( - at: item.folderURL, - includingPropertiesForKeys: nil, - options: [.skipsPackageDescendants] - ) + if let source, case .connectedDevice(_, let container) = source.origin { + try await materializeConnectedDeviceItem( + item, + 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 destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent) - try fileManager.copyItem(at: entryURL, to: destinationURL) + let contents = try fileManager.contentsOfDirectory( + at: item.folderURL, + includingPropertiesForKeys: nil, + options: [.skipsPackageDescendants] + ) + + for entryURL in contents { + let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent) + try fileManager.copyItem(at: entryURL, to: destinationURL) + } } } catch { throw ExportError.failedToPrepareArchiveContents( @@ -139,6 +148,40 @@ enum ContentPackageExporter { 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 { let baseDirectoryURL = try fileManager.url( for: .cachesDirectory, diff --git a/World Manager for Minecraft/Services/ImageCacheStore.swift b/World Manager for Minecraft/Services/ImageCacheStore.swift index c0a073a..953d430 100644 --- a/World Manager for Minecraft/Services/ImageCacheStore.swift +++ b/World Manager for Minecraft/Services/ImageCacheStore.swift @@ -76,6 +76,33 @@ actor ImageCacheStore { 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) { guard let cachedFiles = try? fileManager.contentsOfDirectory( at: cacheDirectoryURL, diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 385fc1e..233e8f0 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -23,13 +23,33 @@ struct SidebarFooterState { 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 final class SourceLibrary: ObservableObject { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 private static let minimumVisibleScanDuration: TimeInterval = 0.8 + private static let connectedDeviceRefreshInterval: TimeInterval = 0.5 @Published var sources: [MinecraftSource] = [] + @Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published private(set) var sidebarFooterState = SidebarFooterState( style: .idle, title: "", @@ -40,20 +60,47 @@ final class SourceLibrary: ObservableObject { @Published private(set) var isRestoringPersistedSources = true private var scanTasks: [URL: Task] = [:] + private var connectedDeviceRefreshTask: Task? private var footerResetTask: Task? private let persistenceStore: SourcePersistenceStore private let sourceAccessMethod: SourceAccessMethod + private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? + private var lastMatchedConnectedSourceIDs: Set = [] init( persistenceStore: SourcePersistenceStore = .shared, - sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess() + sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(), + connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil ) { self.persistenceStore = persistenceStore self.sourceAccessMethod = sourceAccessMethod + self.connectedDeviceAccessMethod = connectedDeviceAccessMethod Task { [weak self] in 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 { @@ -65,12 +112,22 @@ final class SourceLibrary: ObservableObject { if source.bookmarkData == nil { source.bookmarkData = bookmarkData } + source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) } startScan(for: 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) } @@ -79,6 +136,8 @@ final class SourceLibrary: ObservableObject { if sources.contains(where: { $0.id == source.id }) { updateSource(source.id) { existingSource in existingSource.origin = source.origin + existingSource.accessDescriptor = source.accessDescriptor + existingSource.availability = source.availability if existingSource.bookmarkData == nil { existingSource.bookmarkData = source.bookmarkData } @@ -87,11 +146,13 @@ final class SourceLibrary: ObservableObject { } } } else { - sources.append(source) + var resolvedSource = source + resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource) + sources.append(resolvedSource) sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } } - if shouldPersist, source.origin.kind == .localFolder { + if shouldPersist { persistSourceIfAvailable(withID: source.id) } if shouldScan { @@ -109,11 +170,23 @@ final class SourceLibrary: ObservableObject { 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) { + let removedSource = source(withID: sourceID) scanTasks[sourceID]?.cancel() scanTasks[sourceID] = nil sources.removeAll { $0.id == sourceID } deletePersistedSource(withID: sourceID) + if let removedSource { + purgeCachedArtifacts(for: removedSource) + } refreshSidebarFooterState() } @@ -191,45 +264,10 @@ final class SourceLibrary: ObservableObject { 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 source.isScanning = true source.scanError = nil - source.scanStatus = "Scanning Minecraft library..." + source.scanStatus = initialScanStatus(for: source) source.displayItems = [] source.rawItems = [] source.logicalPacks = [] @@ -242,8 +280,30 @@ final class SourceLibrary: ObservableObject { } 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 { - let index = SourceIndexActor(sourceID: sourceID, folderURL: scanRootURL) + let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL) let enrichmentQueue = EnrichmentWorkQueue() let sizeQueue = EnrichmentWorkQueue() workerTasks = (0.. { continuation in + let accessMethod = sourceAccessMethod let discoveryTask = Task.detached(priority: .userInitiated) { do { - _ = try WorldScanner.discoverItems(in: scanRootURL) { item in + _ = try await accessMethod.discoverItems(for: source) { item in continuation.yield(item) } continuation.finish() @@ -353,7 +414,11 @@ final class SourceLibrary: ObservableObject { applySnapshot(snapshot, to: sourceID) } 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) refreshSidebarFooterState() @@ -363,10 +428,12 @@ final class SourceLibrary: ObservableObject { } updateSource(sourceID) { source in + source.availability = availabilityStatus(for: error, defaultingTo: source.availability) source.scanError = "Failed to scan folder: \(error.localizedDescription)" source.scanStatus = "" source.isScanning = false } + persistSourceIfAvailable(withID: sourceID) 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() + + 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) { + 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 { defer { isRestoringPersistedSources = false @@ -765,7 +1040,14 @@ final class SourceLibrary: ObservableObject { } 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.rawItems = await restoreCachedImages(in: record.rawItems) source.indexedItemCount = record.rawItems.count @@ -788,9 +1070,11 @@ final class SourceLibrary: ObservableObject { for record in records { if sourceNeedsRescan(record) { - startScan(for: record.folderURL) + startScan(for: record.sourceID) } } + + await refreshConnectedDevices() } private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] { @@ -828,6 +1112,10 @@ final class SourceLibrary: ObservableObject { } private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool { + guard record.accessDescriptor.refreshStrategy == .eagerFullScan else { + return record.rawItems.isEmpty + } + guard let snapshot = record.snapshot else { return true } @@ -949,9 +1237,14 @@ final class SourceLibrary: ObservableObject { } private func deletePersistedSource(withID sourceID: URL) { - let normalizedSourceID = sourceID.standardizedFileURL 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 } - private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) { - Task.detached(priority: .utility) { [sourceAccessMethod] in - await sourceAccessMethod.releaseScanRoot(preparedScanRoot) - } - } - private func refreshSidebarFooterState() { if isRestoringPersistedSources { cancelFooterReset() @@ -1030,6 +1317,24 @@ final class SourceLibrary: ObservableObject { 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) { cancelFooterReset() footerResetTask = Task { @MainActor [weak self] in @@ -1044,10 +1349,11 @@ final class SourceLibrary: ObservableObject { private func buildSnapshot( for source: MinecraftSource, + scanRootURL: URL, packMetadataByItemID: [URL: PackMetadata] ) -> SourceSnapshot { 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 { return nil } @@ -1075,7 +1381,7 @@ final class SourceLibrary: ObservableObject { } 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] return ItemSnapshot( id: item.id, @@ -1089,7 +1395,7 @@ final class SourceLibrary: ObservableObject { lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending } - let rootModifiedDate = try? source.folderURL + let rootModifiedDate = try? scanRootURL .resourceValues(forKeys: [.contentModificationDateKey]) .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 { let candidateEmbedded = isEmbeddedWorldPack(candidate) let existingEmbedded = isEmbeddedWorldPack(existing) diff --git a/World Manager for Minecraft/Services/SourcePersistenceStore.swift b/World Manager for Minecraft/Services/SourcePersistenceStore.swift index 9fba9b2..f9bd70d 100644 --- a/World Manager for Minecraft/Services/SourcePersistenceStore.swift +++ b/World Manager for Minecraft/Services/SourcePersistenceStore.swift @@ -9,7 +9,11 @@ import Foundation import SQLite3 struct PersistedSourceRecord: Sendable { + let sourceID: URL let folderURL: URL + let origin: MinecraftSourceOrigin + let accessDescriptor: SourceAccessDescriptor + let availability: SourceAvailability let bookmarkData: Data? let displayName: String let rawItems: [MinecraftContentItem] @@ -122,13 +126,13 @@ private struct PersistedCollectionSnapshotPayload: Codable, Sendable { } private struct PersistedSourceSnapshotPayload: Codable, Sendable { - let sourcePath: String + let sourceIdentifier: String let rootModifiedDate: Date? let collectionSnapshots: [PersistedCollectionSnapshotPayload] let itemSnapshots: [PersistedItemSnapshotPayload] nonisolated init(_ snapshot: SourceSnapshot) { - self.sourcePath = snapshot.sourceID.path + self.sourceIdentifier = snapshot.sourceID.absoluteString self.rootModifiedDate = snapshot.rootModifiedDate self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init) self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init) @@ -136,7 +140,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable { nonisolated var sourceSnapshot: SourceSnapshot { SourceSnapshot( - sourceID: URL(fileURLWithPath: sourcePath), + sourceID: URL(string: sourceIdentifier) ?? URL(fileURLWithPath: sourceIdentifier), rootModifiedDate: rootModifiedDate, collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot), itemSnapshots: itemSnapshots.map(\.itemSnapshot) @@ -145,7 +149,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable { nonisolated init(from decoder: any Decoder) throws { 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.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots) 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 { 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.encode(collectionSnapshots, forKey: .collectionSnapshots) try container.encode(itemSnapshots, forKey: .itemSnapshots) } private enum CodingKeys: String, CodingKey { - case sourcePath + case sourceIdentifier case rootModifiedDate case collectionSnapshots case itemSnapshots @@ -189,7 +193,8 @@ actor SourcePersistenceStore { defer { sqlite3_close(database) } 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 ORDER BY display_name COLLATE NOCASE ASC; """ @@ -203,23 +208,38 @@ actor SourcePersistenceStore { var records: [PersistedSourceRecord] = [] 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 } + let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL let folderPath = String(cString: folderPathPointer) - let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 1) - let displayName = String(cString: sqlite3_column_text(statement, 2)) - let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 3) ?? [] - let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 4) + let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: 2) + ?? .localFolder(bookmarkData: nil) + let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: 3) + ?? 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 lastScanDate = sqlite3_column_type(statement, 5) == SQLITE_NULL + let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil - : Date(timeIntervalSince1970: sqlite3_column_double(statement, 5)) + : Date(timeIntervalSince1970: sqlite3_column_double(statement, 9)) records.append( PersistedSourceRecord( + sourceID: sourceID, folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL, + origin: origin, + accessDescriptor: accessDescriptor, + availability: availability, bookmarkData: bookmarkData, displayName: displayName, rawItems: rawItems, @@ -238,15 +258,23 @@ actor SourcePersistenceStore { let sql = """ INSERT INTO source_cache ( + source_id, folder_path, + origin_json, + access_descriptor_json, + availability_state, bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date - ) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(folder_path) DO UPDATE SET + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_id) DO UPDATE SET 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, raw_items_json = excluded.raw_items_json, snapshot_json = excluded.snapshot_json, @@ -259,16 +287,20 @@ actor SourcePersistenceStore { } defer { sqlite3_finalize(statement) } - try bindText(source.folderURL.path, to: statement, at: 1) - try bindData(source.bookmarkData, to: statement, at: 2) - try bindText(source.displayName, to: statement, at: 3) - try bindJSON(source.rawItems, to: statement, at: 4) - try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 5) + try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1) + try bindText(source.folderURL.path, to: statement, at: 2) + try bindJSON(source.origin, to: statement, at: 3) + try bindJSON(source.accessDescriptor, to: statement, at: 4) + 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 { - sqlite3_bind_double(statement, 6, lastScanDate.timeIntervalSince1970) + sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970) } else { - sqlite3_bind_null(statement, 6) + sqlite3_bind_null(statement, 10) } guard sqlite3_step(statement) == SQLITE_DONE else { @@ -280,14 +312,15 @@ actor SourcePersistenceStore { let database = try openDatabase() 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? guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw databaseError(database) } 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 { throw databaseError(database) @@ -309,7 +342,11 @@ actor SourcePersistenceStore { try execute( """ CREATE TABLE IF NOT EXISTS source_cache ( + source_id TEXT, folder_path TEXT PRIMARY KEY, + origin_json BLOB, + access_descriptor_json BLOB, + availability_state TEXT, bookmark_data BLOB, display_name TEXT NOT NULL, raw_items_json BLOB NOT NULL, @@ -319,15 +356,59 @@ actor SourcePersistenceStore { """, 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( - "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", - on: database, - ignoringDuplicateColumn: true + """ + UPDATE source_cache + 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 } + private func columns(in tableName: String, on database: OpaquePointer?) throws -> Set { + 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() + 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, + 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 { guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { if ignoringDuplicateColumn, @@ -401,6 +482,34 @@ actor SourcePersistenceStore { 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 { persistenceError(String(cString: sqlite3_errmsg(database))) } diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index ba5d3f3..ea6272e 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -21,42 +21,39 @@ struct SidebarFilter: Identifiable, Hashable { } struct SourcesSidebarView: View { - let sources: [MinecraftSource] + let localSources: [MinecraftSource] + let connectedDevices: [ConnectedDeviceSidebarEntry] @Binding var selection: SidebarSelection? let footerState: SidebarFooterState let addSourceAction: () -> Void let addDeviceSourceAction: () -> Void + let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void let rescanSourceAction: (MinecraftSource) -> Void let removeSourceAction: (MinecraftSource) -> Void let revealFooterURLAction: (URL) -> Void let filters: (MinecraftSource) -> [SidebarFilter] + let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource? var body: some View { List(selection: $selection) { - Section { - ForEach(sources) { source in - 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?) + if !localSources.isEmpty { + Section { + ForEach(localSources) { source in + sourceSectionRows(for: source) } + } header: { + SidebarSourcesSectionHeaderView(title: "Libraries") + } + } + + if !connectedDevices.isEmpty { + Section { + ForEach(connectedDevices) { entry in + connectedDeviceSectionRows(for: entry) + } + } header: { + SidebarSourcesSectionHeaderView(title: "Connected Devices") } - } header: { - SidebarSourcesSectionHeaderView() } } .listStyle(.sidebar) @@ -88,6 +85,45 @@ struct SourcesSidebarView: View { } .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 { @@ -112,8 +148,10 @@ private struct SidebarFilterRow: View { } private struct SidebarSourcesSectionHeaderView: View { + let title: String + var body: some View { - Text("Libraries") + Text(title) .font(.headline) .foregroundStyle(.secondary) .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 { let state: SidebarFooterState let revealAction: (URL) -> Void diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift index 63ae24e..57503cc 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift @@ -22,6 +22,22 @@ struct AppleMobileDeviceApplicationSummary: Sendable { 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 { static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary { try await Task.detached(priority: .userInitiated) { @@ -113,10 +129,158 @@ enum AppleMobileDeviceAccess { return AppleMobileDeviceApplicationSummary( bundleIdentifier: bundleIdentifier, displayName: displayName, - fileSharingEnabled: application["uiFileSharingEnabled"] as? Bool ?? false, - supportsOpeningDocumentsInPlace: application["supportsOpeningDocumentsInPlace"] as? Bool ?? false + fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]), + supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"]) ) } }.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 + } + } } diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h index 0bf78e1..68582e7 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h @@ -37,6 +37,27 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults( NSError **error ); +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot( + NSString *bundleIdentifier, + NSString *relativePath, + NSError **error +); + +FOUNDATION_EXPORT NSData * _Nullable +WMMCopyFirstConnectedDeviceAppFileData( + NSString *bundleIdentifier, + NSString *relativePath, + NSError **error +); + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceAppPathMetrics( + NSString *bundleIdentifier, + NSString *relativePath, + NSError **error +); + FOUNDATION_EXPORT BOOL WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( NSString *bundleIdentifier, diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m index f18316d..0784a54 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m @@ -9,6 +9,7 @@ #import #import +#import 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 (*AFCDirectoryReadFn)(AFCConnectionRef connection, AFCDirectoryRef directory, char **directoryEntry); 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 (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length); typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor); @@ -137,6 +141,9 @@ typedef struct { AFCDirectoryOpenFn AFCDirectoryOpen; AFCDirectoryReadFn AFCDirectoryRead; AFCDirectoryCloseFn AFCDirectoryClose; + AFCFileInfoOpenFn AFCFileInfoOpen; + AFCKeyValueReadFn AFCKeyValueRead; + AFCKeyValueCloseFn AFCKeyValueClose; AFCFileRefOpenFn AFCFileRefOpen; AFCFileRefReadFn AFCFileRefRead; AFCFileRefCloseFn AFCFileRefClose; @@ -209,6 +216,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro functions->AFCDirectoryOpen = (AFCDirectoryOpenFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryOpen"); functions->AFCDirectoryRead = (AFCDirectoryReadFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryRead"); 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->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead"); functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose"); @@ -241,6 +251,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro functions->AFCDirectoryOpen == NULL || functions->AFCDirectoryRead == NULL || functions->AFCDirectoryClose == NULL || + functions->AFCFileInfoOpen == NULL || + functions->AFCKeyValueRead == NULL || + functions->AFCKeyValueClose == NULL || functions->AFCFileRefOpen == NULL || functions->AFCFileRefRead == NULL || functions->AFCFileRefClose == NULL) { @@ -291,7 +304,7 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio return NULL; } - CFRunLoopRunInMode(kCFRunLoopDefaultMode, 2.0, false); + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false); functions->AMDeviceNotificationUnsubscribe(subscription); if (context.device == NULL && error != NULL) { @@ -570,6 +583,166 @@ static int WMMReadAFCDirectory( return result; } +static NSDictionary * _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 *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 *info) { + NSString *candidate = info[@"st_mtime"] ?: info[@"st_birthtime"]; + if (candidate.length == 0) { + return nil; + } + + return WMMDateFromAFCTimestampString(candidate); +} + +static long long WMMFileSizeFromAFCInfo(NSDictionary *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 * _Nullable WMMCopyAFCTreeMetrics( + WMMMobileDeviceFunctions *functions, + AFCConnectionRef afcConnection, + NSString *remotePath, + NSError **error +) { + NSDictionary *info = WMMCopyAFCFileInfo(functions, afcConnection, remotePath, error); + if (info == nil) { + return nil; + } + + NSDate *latestModificationDate = WMMModificationDateFromAFCInfo(info); + NSMutableArray *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 *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( WMMMobileDeviceFunctions *functions, AFCConnectionRef afcConnection, @@ -596,6 +769,14 @@ static BOOL WMMCopyAFCFileToLocalURL( NSFileHandle *handle = [NSFileHandle fileHandleForWritingToURL:localFileURL error:error]; 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); return NO; } @@ -624,6 +805,14 @@ static BOOL WMMCopyAFCFileToLocalURL( NSData *chunk = [NSData dataWithBytes:buffer.bytes length:bytesToRead]; 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; break; } @@ -646,6 +835,14 @@ static BOOL WMMCopyAFCTreeToLocalURL( if (directoryStatus == 0) { NSFileManager *fileManager = [NSFileManager defaultManager]; 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; } @@ -660,8 +857,16 @@ static BOOL WMMCopyAFCTreeToLocalURL( } else { childRemotePath = [childRemotePath stringByAppendingPathComponent:entry]; } - NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry isDirectory:YES]; + NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry]; 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; } } @@ -672,6 +877,296 @@ static BOOL WMMCopyAFCTreeToLocalURL( 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 *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 * _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 *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 *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 *WMMBuildMinecraftItemSummary( + WMMMobileDeviceFunctions *functions, + AFCConnectionRef afcConnection, + NSString *contentType, + NSString *collectionFolderName, + NSString *itemRemotePath, + NSString *itemRelativePath, + NSString *folderName, + NSArray *entries +) { + NSMutableDictionary *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 *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 *> *results +) { + NSString *collectionRemotePath = [rootRemotePath stringByAppendingPathComponent:collectionFolderName]; + NSMutableArray *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 *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 *> *embeddedCollections = @[ + @{ @"folder": @"behavior_packs", @"type": @"Behavior Pack" }, + @{ @"folder": @"resource_packs", @"type": @"Resource Pack" } + ]; + + for (NSDictionary *embeddedCollection in embeddedCollections) { + NSString *embeddedFolder = embeddedCollection[@"folder"]; + NSString *embeddedType = embeddedCollection[@"type"]; + NSString *embeddedCollectionPath = [itemRemotePath stringByAppendingPathComponent:embeddedFolder]; + NSMutableArray *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 *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 * _Nullable WMMCopyFirstConnectedDeviceSummary(NSError **error) { WMMMobileDeviceFunctions functions; @@ -996,6 +1491,207 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) { }; } +NSDictionary * _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 *> *items = [NSMutableArray array]; + NSArray *> *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 *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 * _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 *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 * _Nullable WMMCopyFirstConnectedDeviceApplicationDetails( NSString *bundleIdentifier, diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift index cded998..6cebf43 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -8,13 +8,42 @@ import Foundation struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { - private let mirrorRootURL: URL + nonisolated let accessorIdentifier: SourceAccessorIdentifier = "connected-device.apple-mobile-device" - nonisolated init( - mirrorRootURL: URL = FileManager.default.temporaryDirectory - .appendingPathComponent("WorldManagerConnectedDevices", isDirectory: true) - ) { - self.mirrorRootURL = mirrorRootURL + nonisolated init() {} + + nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { + _ = source + 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] { @@ -35,7 +64,11 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { let applications = try await AppleMobileDeviceAccess.listApplications() return applications - .filter { $0.fileSharingEnabled } + .filter { application in + application.fileSharingEnabled + || application.supportsOpeningDocumentsInPlace + || application.bundleIdentifier == "com.mojang.minecraftpe" + } .map { application in DeviceAppContainer( 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 { throw SourceAccessError.accessFailed( reason: "The selected source is not backed by a connected mobile device." @@ -72,38 +108,421 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { ) } - let fileManager = FileManager.default - let mirrorURL = mirrorRootURL - .appendingPathComponent(container.deviceUDID, isDirectory: true) - .appendingPathComponent(container.appID.replacingOccurrences(of: ".", with: "_"), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) + let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot( + bundleIdentifier: container.appID, + relativePath: requestedSubpath + ) - do { - try fileManager.createDirectory(at: mirrorURL, withIntermediateDirectories: true) - try await AppleMobileDeviceAccess.mirrorSubtree( - bundleIdentifier: container.appID, - relativePath: requestedSubpath, - destinationDirectoryURL: mirrorURL - ) - } catch { - try? fileManager.removeItem(at: mirrorURL) - throw SourceAccessError.accessFailed(reason: error.localizedDescription) + let items = summaries.compactMap { summary in + makeItem(from: summary, source: source) } - return PreparedScanRoot( - sourceID: source.id, - rootURL: mirrorURL, - mountPointURL: mirrorURL, - cleanupBehavior: .deleteTemporaryDirectory - ) + for item in items { + onDiscovered(item) + } + + return items } - nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async { - guard case .deleteTemporaryDirectory = preparedScanRoot.cleanupBehavior, - let mountPointURL = preparedScanRoot.mountPointURL else { + nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { + var enrichedItem = item + 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 } - 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() + 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 + } } } diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceMirrorCache.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceMirrorCache.swift new file mode 100644 index 0000000..60bfd3f --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceMirrorCache.swift @@ -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)) + } +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift index 93c0905..d6e2ba4 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift @@ -15,12 +15,18 @@ struct ConnectedDeviceSourceFactory: Sendable { container: DeviceAppContainer ) -> MinecraftSource { 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( sourceID: sourceID, - folderURL: placeholderFolderURL, - origin: .connectedDevice(device: device, container: container) + folderURL: cacheRootURL, + origin: .connectedDevice(device: device, container: container), + accessDescriptor: SourceAccessDescriptor( + accessorIdentifier: AppleMobileDeviceSourceAccess().accessorIdentifier, + kind: .connectedDevice, + capabilities: .connectedDevice, + refreshStrategy: .staged + ) ) source.displayName = displayName(for: device, container: container) return source diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index 9eceba4..48d0345 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -8,13 +8,71 @@ import Foundation protocol SourceAccessMethod: Sendable { - nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot - nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async + nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } + 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 { - nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async { - _ = preparedScanRoot + nonisolated var accessorIdentifier: SourceAccessorIdentifier { + 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 { - private let localFolderAccess: SourceAccessMethod - private let connectedDeviceAccess: ConnectedDeviceSourceAccessMethod + private let accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod] nonisolated init( localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(), connectedDeviceAccess: ConnectedDeviceSourceAccessMethod ) { - self.localFolderAccess = localFolderAccess - self.connectedDeviceAccess = connectedDeviceAccess + self.init(accessMethods: [localFolderAccess, connectedDeviceAccess]) } - nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot { - switch source.origin { - case .localFolder: - return try await localFolderAccess.prepareScanRoot(for: source) - case .connectedDevice: - return try await connectedDeviceAccess.prepareScanRoot(for: source) + nonisolated init(accessMethods: [any SourceAccessMethod]) { + var accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod] = [:] + for accessMethod in accessMethods { + accessMethodsByIdentifier[accessMethod.accessorIdentifier] = accessMethod } + self.accessMethodsByIdentifier = accessMethodsByIdentifier } - nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async { - await localFolderAccess.releaseScanRoot(preparedScanRoot) - await connectedDeviceAccess.releaseScanRoot(preparedScanRoot) + nonisolated private func accessMethod(for source: MinecraftSource) -> (any SourceAccessMethod) { + if let accessMethod = accessMethodsByIdentifier[source.accessDescriptor.accessorIdentifier] { + 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) } } diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index 7147f9a..2018a61 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -8,9 +8,46 @@ import Foundation struct LocalFolderSourceAccess: SourceAccessMethod { + nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder" + 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 { throw SourceAccessError.accessFailed( reason: "No local-folder access method is configured for this source type." @@ -36,11 +73,51 @@ struct LocalFolderSourceAccess: SourceAccessMethod { resolvedURL = source.folderURL } - return PreparedScanRoot( - sourceID: source.id, - rootURL: resolvedURL, - mountPointURL: nil, - cleanupBehavior: .none + let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource() + defer { + if accessedSecurityScope { + resolvedURL.stopAccessingSecurityScopedResource() + } + } + + 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 } }