diff --git a/World Manager for Minecraft/ContentUIShared.swift b/World Manager for Minecraft/ContentUIShared.swift index 13b5a65..3480086 100644 --- a/World Manager for Minecraft/ContentUIShared.swift +++ b/World Manager for Minecraft/ContentUIShared.swift @@ -1,58 +1,21 @@ import AppKit import SwiftUI -struct SharingPickerButton: NSViewRepresentable { - let title: String? +struct ToolbarShareButton: View { let systemImage: String let isEnabled: Bool - let action: (NSView) -> Void + let action: (NSView?) -> Void + @State private var anchorView: NSView? - func makeCoordinator() -> Coordinator { - Coordinator(action: action) - } - - func makeNSView(context: Context) -> NSButton { - let button = NSButton() - button.target = context.coordinator - button.action = #selector(Coordinator.didPressButton(_:)) - button.isBordered = false - button.bezelStyle = .regularSquare - button.contentTintColor = .white - button.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) - update(button) - return button - } - - func updateNSView(_ nsView: NSButton, context: Context) { - context.coordinator.action = action - update(nsView) - } - - private func update(_ button: NSButton) { - button.image = NSImage( - systemSymbolName: systemImage, - accessibilityDescription: title ?? "Share" - ) - button.imagePosition = title == nil ? .imageOnly : .imageLeading - button.isEnabled = isEnabled - button.attributedTitle = NSAttributedString( - string: title ?? "", - attributes: [ - .foregroundColor: NSColor.white, - .font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) - ] - ) - } - - final class Coordinator: NSObject { - var action: (NSView) -> Void - - init(action: @escaping (NSView) -> Void) { - self.action = action + var body: some View { + Button { + action(anchorView) + } label: { + Image(systemName: systemImage) } - - @objc func didPressButton(_ sender: NSButton) { - action(sender) + .disabled(!isEnabled) + .background { + ShareAnchorView(anchorView: $anchorView) } } } diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 7804eb2..d2483bc 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -587,6 +587,7 @@ struct ContentView: View { guard !isPerformingItemAction else { return } + let source = currentSource let panel = NSSavePanel() panel.canCreateDirectories = true @@ -605,12 +606,10 @@ struct ContentView: View { Task { do { - try await Task.detached(priority: .userInitiated) { - try ContentPackageExporter.exportItem(item, to: destinationURL) + let finalURL = try await Task.detached(priority: .userInitiated) { + try ContentPackageExporter.createArchiveFile(for: item, source: source, destinationURL: destinationURL) }.value - let finalURL = ContentPackageExporter.finalArchiveURL(for: item, destinationURL: destinationURL) - await MainActor.run { isPerformingItemAction = false library.setItemActionSuccess( @@ -632,6 +631,7 @@ struct ContentView: View { guard !isPerformingItemAction else { return } + let source = currentSource isPerformingItemAction = true library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...") @@ -639,7 +639,7 @@ struct ContentView: View { Task { do { let shareURL = try await Task.detached(priority: .userInitiated) { - try ContentPackageExporter.prepareShareFile(for: item) + try ContentPackageExporter.createArchiveFile(for: item, source: source) }.value await MainActor.run { diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index 426b2b2..02ed903 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -53,21 +53,24 @@ struct ItemDetailColumnView: View { } .toolbar { if item != nil { - ToolbarItemGroup { + ToolbarItem { Button(action: exportAction) { Image(systemName: "arrow.down.circle") } .disabled(isPerformingItemAction) .help(exportTitle ?? "Export") + } + ToolbarItem { Button(action: revealAction) { Image(systemName: "folder") } .disabled(isPerformingItemAction) .help("Reveal in Finder") + } - SharingPickerButton( - title: nil, + ToolbarItem { + ToolbarShareButton( systemImage: "square.and.arrow.up", isEnabled: !isPerformingItemAction ) { anchorView in diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 1a3ffe4..634b1b3 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -10,6 +10,7 @@ import Foundation struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL + var origin: MinecraftSourceOrigin var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] @@ -26,10 +27,15 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var indexedDetailCount: Int var lastScanDate: Date? - init(folderURL: URL, bookmarkData: Data? = nil) { + init( + folderURL: URL, + bookmarkData: Data? = nil, + origin: MinecraftSourceOrigin? = nil + ) { let normalizedURL = folderURL.standardizedFileURL self.id = normalizedURL self.folderURL = normalizedURL + self.origin = origin ?? .localFolder(bookmarkData: bookmarkData) self.bookmarkData = bookmarkData self.displayName = normalizedURL.lastPathComponent self.displayItems = [] diff --git a/World Manager for Minecraft/Models/SourceOrigin.swift b/World Manager for Minecraft/Models/SourceOrigin.swift new file mode 100644 index 0000000..8f13a5b --- /dev/null +++ b/World Manager for Minecraft/Models/SourceOrigin.swift @@ -0,0 +1,78 @@ +// +// SourceOrigin.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +struct ConnectedDevice: Identifiable, Hashable, Sendable, Codable { + let udid: String + var name: String + var productType: String? + var osVersion: String? + var connection: DeviceConnection + var trustState: DeviceTrustState + + var id: String { udid } +} + +enum DeviceConnection: String, Hashable, Sendable, Codable { + case usb + case network +} + +enum DeviceTrustState: String, Hashable, Sendable, Codable { + case unavailable + case locked + case untrusted + case trusted +} + +struct DeviceAppContainer: Identifiable, Hashable, Sendable, Codable { + let deviceUDID: String + let appID: String + var appName: String + var accessMode: DeviceContainerAccessMode + var minecraftFolderRelativePath: String? + + var id: String { + [deviceUDID, appID, accessMode.rawValue].joined(separator: "::") + } +} + +enum DeviceContainerAccessMode: String, Hashable, Sendable, Codable { + case documents + case container +} + +enum MinecraftSourceOrigin: Hashable, Sendable, Codable { + case localFolder(bookmarkData: Data?) + case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer) + + var kind: MinecraftSourceKind { + switch self { + case .localFolder: + return .localFolder + case .connectedDevice: + return .connectedDevice + } + } +} + +enum MinecraftSourceKind: String, Hashable, Sendable, Codable { + case localFolder + case connectedDevice +} + +struct PreparedScanRoot: Hashable, Sendable { + let sourceID: URL + let rootURL: URL + let cleanupBehavior: CleanupBehavior + + enum CleanupBehavior: Hashable, Sendable { + case none + case unmount + } +} diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift index 11a71ed..276ebd2 100644 --- a/World Manager for Minecraft/Services/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift @@ -5,53 +5,43 @@ // Created by John Burwell on 2026-05-25. // +import CryptoKit import Foundation enum ContentPackageExporter { enum ExportError: LocalizedError { case failedToCreateArchive(String) + case failedToPrepareArchiveContents(String) var errorDescription: String? { switch self { case .failedToCreateArchive(let output): return output.isEmpty ? "Failed to create the archive file." : output + case .failedToPrepareArchiveContents(let message): + return message } } } - nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws { + nonisolated static func createArchiveFile( + for item: MinecraftContentItem, + source: MinecraftSource? = nil, + destinationURL: URL? = nil + ) throws -> URL { let fileManager = FileManager.default - let archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL) - let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager) + let archiveURL: URL - defer { - try? fileManager.removeItem(at: temporaryArchiveURL) + if let destinationURL { + archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL) + } else { + archiveURL = try shareArchiveURL(for: item, fileManager: fileManager) } - try createArchive(for: item, at: temporaryArchiveURL) - if fileManager.fileExists(atPath: archiveURL.path) { try fileManager.removeItem(at: archiveURL) } - try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL) - } - - nonisolated static func prepareShareFile(for item: MinecraftContentItem) throws -> URL { - let fileManager = FileManager.default - let shareDirectoryURL = fileManager.temporaryDirectory - .appendingPathComponent("MinecraftContentShares", isDirectory: true) - - try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true) - - let archiveURL = uniqueArchiveURL( - in: shareDirectoryURL, - baseName: suggestedBaseFilename(for: item), - pathExtension: item.contentType.archiveExtension, - fileManager: fileManager - ) - - try createArchive(for: item, at: archiveURL) + try createArchive(for: item, source: source, at: archiveURL) return archiveURL } @@ -67,10 +57,25 @@ enum ContentPackageExporter { normalizedArchiveURL(for: item, destinationURL: destinationURL) } - nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws { + nonisolated private static func createArchive( + for item: MinecraftContentItem, + source: MinecraftSource?, + at archiveURL: URL + ) throws { + let fileManager = FileManager.default + let stagingDirectoryURL = try stagedArchiveContents(for: item, source: source, fileManager: fileManager) + + defer { + try? fileManager.removeItem(at: stagingDirectoryURL) + } + + if fileManager.fileExists(atPath: archiveURL.path) { + try fileManager.removeItem(at: archiveURL) + } + let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.currentDirectoryURL = item.folderURL + process.currentDirectoryURL = stagingDirectoryURL process.arguments = [ "-c", "-k", @@ -95,6 +100,135 @@ enum ContentPackageExporter { } } + nonisolated private static func stagedArchiveContents( + for item: MinecraftContentItem, + source: MinecraftSource?, + fileManager: FileManager + ) 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] + ) + + for entryURL in contents { + let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent) + try fileManager.copyItem(at: entryURL, to: destinationURL) + } + } catch { + throw ExportError.failedToPrepareArchiveContents( + "Could not prepare the item for archiving: \(error.localizedDescription)" + ) + } + + return stagingDirectoryURL + } + + nonisolated private static func shareArchiveDirectory(fileManager: FileManager) throws -> URL { + let baseDirectoryURL = try fileManager.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let shareDirectoryURL = baseDirectoryURL + .appendingPathComponent("MinecraftContentShares", isDirectory: true) + + try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true) + return shareDirectoryURL + } + + nonisolated private static func shareArchiveURL( + for item: MinecraftContentItem, + fileManager: FileManager + ) throws -> URL { + let shareDirectoryURL = try shareArchiveDirectory(fileManager: fileManager) + let itemDirectoryURL = shareDirectoryURL + .appendingPathComponent(shareCacheKey(for: item), isDirectory: true) + let requestDirectoryURL = itemDirectoryURL + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try fileManager.createDirectory(at: requestDirectoryURL, withIntermediateDirectories: true) + + cleanupShareArchives(in: itemDirectoryURL, keeping: requestDirectoryURL, fileManager: fileManager) + + return requestDirectoryURL + .appendingPathComponent(suggestedBaseFilename(for: item)) + .appendingPathExtension(item.contentType.archiveExtension) + } + + nonisolated private static func shareCacheKey(for item: MinecraftContentItem) -> String { + let digest = SHA256.hash(data: Data(item.id.path.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + nonisolated private static func cleanupShareArchives( + in itemDirectoryURL: URL, + keeping currentDirectoryURL: URL, + fileManager: FileManager + ) { + guard let childDirectoryURLs = try? fileManager.contentsOfDirectory( + at: itemDirectoryURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let staleDirectories = childDirectoryURLs + .filter { $0 != currentDirectoryURL } + .sorted { lhs, rhs in + let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + return lhsDate > rhsDate + } + .dropFirst(2) + + for directoryURL in staleDirectories { + try? fileManager.removeItem(at: directoryURL) + } + } + + nonisolated private static func archiveAccessURL( + for item: MinecraftContentItem, + source: MinecraftSource? + ) throws -> URL { + guard let source else { + return item.folderURL + } + + guard case .localFolder(let bookmarkData) = source.origin else { + return source.folderURL + } + + guard let bookmarkData else { + return source.folderURL + } + + var isStale = false + return try URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ).standardizedFileURL + } + nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL { let normalizedDestinationURL = destinationURL.standardizedFileURL let requiredExtension = item.contentType.archiveExtension @@ -106,12 +240,6 @@ enum ContentPackageExporter { return normalizedDestinationURL.appendingPathExtension(requiredExtension) } - nonisolated private static func temporaryArchiveURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL { - fileManager.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension(item.contentType.archiveExtension) - } - nonisolated private static func uniqueArchiveURL( in directoryURL: URL, baseName: String, diff --git a/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift b/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift new file mode 100644 index 0000000..4cff3c3 --- /dev/null +++ b/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift @@ -0,0 +1,87 @@ +// +// DeviceAccessCoordinator.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +protocol SourceScanRootPreparing: Sendable { + nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot +} + +protocol DeviceDiscoveryServing: Sendable { + nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] + nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] +} + +protocol DeviceMountServing: Sendable { + nonisolated func prepareScanRoot( + for source: MinecraftSource, + preferredSubpath: String? + ) async throws -> PreparedScanRoot +} + +struct LocalFolderScanRootPreparer: SourceScanRootPreparing { + nonisolated init() {} + + nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot { + guard case .localFolder(let bookmarkData) = source.origin else { + throw DeviceAccessError.mountFailed( + reason: "No scan-root preparer is configured for this source type." + ) + } + + let resolvedURL: URL + if let bookmarkData { + var isStale = false + guard let bookmarkURL = try? URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + throw DeviceAccessError.mountFailed( + reason: "The saved folder bookmark could not be resolved." + ) + } + + resolvedURL = bookmarkURL.standardizedFileURL + } else { + resolvedURL = source.folderURL + } + + return PreparedScanRoot( + sourceID: source.id, + rootURL: resolvedURL, + cleanupBehavior: .none + ) + } +} + +enum DeviceAccessError: LocalizedError, Sendable { + case toolingUnavailable + case deviceUnavailable + case deviceNotTrusted + case appNotAccessible(appID: String) + case minecraftFolderMissing(appID: String) + case mountFailed(reason: String) + + var errorDescription: String? { + switch self { + case .toolingUnavailable: + return "Required device-access tooling is unavailable." + case .deviceUnavailable: + return "The selected device is no longer available." + case .deviceNotTrusted: + return "The device must be unlocked and trusted before its files can be accessed." + case .appNotAccessible(let appID): + return "The app container for \(appID) is not accessible on this device." + case .minecraftFolderMissing(let appID): + return "Minecraft resources were not found in the accessible container for \(appID)." + case .mountFailed(let reason): + return reason + } + } +} diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index e59f853..78946cb 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -42,9 +42,14 @@ final class SourceLibrary: ObservableObject { private var scanTasks: [URL: Task] = [:] private var footerResetTask: Task? private let persistenceStore: SourcePersistenceStore + private let scanRootPreparer: SourceScanRootPreparing - init(persistenceStore: SourcePersistenceStore = .shared) { + init( + persistenceStore: SourcePersistenceStore = .shared, + scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer() + ) { self.persistenceStore = persistenceStore + self.scanRootPreparer = scanRootPreparer Task { [weak self] in await self?.restorePersistedSources() @@ -162,12 +167,27 @@ final class SourceLibrary: ObservableObject { return } - let scanRootURL = resolvedSourceURL(for: source) ?? source.folderURL + let preparedScanRoot: PreparedScanRoot + do { + preparedScanRoot = try await scanRootPreparer.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 { @@ -919,28 +939,19 @@ final class SourceLibrary: ObservableObject { ) } - private func resolvedSourceURL(for source: MinecraftSource) -> URL? { - guard let bookmarkData = source.bookmarkData else { - return nil - } - - var isStale = false - guard let resolvedURL = try? URL( - resolvingBookmarkData: bookmarkData, - options: [.withSecurityScope], - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) else { - return nil - } - - return resolvedURL.standardizedFileURL - } - private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { contentType == .behaviorPack || contentType == .resourcePack } + private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) { + switch preparedScanRoot.cleanupBehavior { + case .none: + return + case .unmount: + return + } + } + private func refreshSidebarFooterState() { if isRestoringPersistedSources { cancelFooterReset()