From d1c7d1de73bfc1daef65df7c1331b793b97f7353 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Fri, 29 May 2026 16:31:57 -0500 Subject: [PATCH] Unify outbound external item representations --- .../Export/ContentItemActionService.swift | 33 +++++++++++ .../Export/ExternalRepresentation.swift | 22 +++++++ .../Services/Sources/Core/SourceLibrary.swift | 55 +++++++++++++++++- .../UI/Root/ContentView.swift | 45 +++++++++----- .../World_Manager_for_MinecraftTests.swift | 58 +++++++++++++++++++ 5 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 World Manager for Minecraft/Services/AppSupport/Export/ExternalRepresentation.swift diff --git a/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift b/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift index 8d2b84a..417eb1a 100644 --- a/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift +++ b/World Manager for Minecraft/Services/AppSupport/Export/ContentItemActionService.swift @@ -1,10 +1,43 @@ import Foundation +import UniformTypeIdentifiers struct ContentItemActionService: Sendable { + nonisolated init() {} + nonisolated func suggestedFilename(for item: MinecraftContentItem) -> String { ContentPackageExporter.suggestedBaseFilename(for: item) } + nonisolated func suggestedArchiveFilename(for item: MinecraftContentItem) -> String { + ContentPackageExporter.suggestedFilename(for: item) + } + + nonisolated func archiveContentType(for item: MinecraftContentItem) -> UTType { + UTType(filenameExtension: item.contentType.archiveExtension) ?? .data + } + + nonisolated func persistExternalRepresentation( + _ representation: ExternalRepresentation, + to destinationURL: URL + ) throws -> URL { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + if representation.url.standardizedFileURL == destinationURL.standardizedFileURL { + return destinationURL + } + + if representation.isTemporary { + try fileManager.moveItem(at: representation.url, to: destinationURL) + } else { + try fileManager.copyItem(at: representation.url, to: destinationURL) + } + + return destinationURL + } + nonisolated func createArchiveFile( for item: MinecraftContentItem, source: MinecraftSource?, diff --git a/World Manager for Minecraft/Services/AppSupport/Export/ExternalRepresentation.swift b/World Manager for Minecraft/Services/AppSupport/Export/ExternalRepresentation.swift new file mode 100644 index 0000000..acc9de1 --- /dev/null +++ b/World Manager for Minecraft/Services/AppSupport/Export/ExternalRepresentation.swift @@ -0,0 +1,22 @@ +// +// ExternalRepresentation.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-29. +// + +import Foundation +import UniformTypeIdentifiers + +enum ExternalRepresentationKind: Hashable, Sendable { + case nativeFolder + case portablePackage +} + +struct ExternalRepresentation: Hashable, Sendable { + let url: URL + let kind: ExternalRepresentationKind + let suggestedFilename: String + let contentType: UTType + let isTemporary: Bool +} diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index ada92de..1d8e392 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -8,6 +8,7 @@ import Combine import Foundation import OSLog +import UniformTypeIdentifiers @MainActor final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { @@ -37,6 +38,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer private let sourceAccessMethod: SourceAccessMethod private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? private let notificationService: ScanNotificationServicing + private let itemActionService: ContentItemActionService private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory() var lastMatchedConnectedSourceIDs: Set = [] var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:] @@ -46,12 +48,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer persistenceStore: SourcePersistenceStore = .shared, sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(), connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil, - notificationService: ScanNotificationServicing? = nil + notificationService: ScanNotificationServicing? = nil, + itemActionService: ContentItemActionService = ContentItemActionService() ) { self.persistenceStore = persistenceStore self.sourceAccessMethod = sourceAccessMethod self.connectedDeviceAccessMethod = connectedDeviceAccessMethod self.notificationService = notificationService ?? ScanNotificationService.shared + self.itemActionService = itemActionService Task { [weak self] in guard let self else { @@ -198,6 +202,38 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer try await sourceAccessMethod.materializeItem(for: item, in: source) } + func externalRepresentation( + for item: MinecraftContentItem, + in source: MinecraftSource, + preferredKind: ExternalRepresentationKind? = nil + ) async throws -> ExternalRepresentation { + let kind = resolvedExternalRepresentationKind( + for: source, + preferredKind: preferredKind + ) + + switch kind { + case .nativeFolder: + let url = try await sourceAccessMethod.materializeItem(for: item, in: source) + return ExternalRepresentation( + url: url, + kind: .nativeFolder, + suggestedFilename: itemActionService.suggestedFilename(for: item), + contentType: .folder, + isTemporary: source.origin.kind != .localFolder + ) + case .portablePackage: + let url = try await itemActionService.createArchiveFile(for: item, source: source) + return ExternalRepresentation( + url: url, + kind: .portablePackage, + suggestedFilename: itemActionService.suggestedArchiveFilename(for: item), + contentType: itemActionService.archiveContentType(for: item), + isTemporary: true + ) + } + } + func removeSource(withID sourceID: URL) { let removedSource = source(withID: sourceID) scanTasks[sourceID]?.cancel() @@ -328,6 +364,23 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } } + private func resolvedExternalRepresentationKind( + for source: MinecraftSource, + preferredKind: ExternalRepresentationKind? + ) -> ExternalRepresentationKind { + switch preferredKind { + case .some(.nativeFolder): + if source.capabilities.canMaterializeItems { + return .nativeFolder + } + return .portablePackage + case .some(.portablePackage): + return .portablePackage + case .none: + return .portablePackage + } + } + private func runConnectedDeviceRefreshLoop() async { guard let connectedDeviceAccessMethod else { return diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 27cc98c..15eaf46 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -660,7 +660,6 @@ struct ContentView: View { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { return } - let source = currentSource let panel = NSSavePanel() panel.canCreateDirectories = true @@ -679,11 +678,22 @@ struct ContentView: View { Task { do { + guard let source = currentSource else { + await MainActor.run { + isPerformingItemAction = false + } + return + } + let finalURL = try await Task.detached(priority: .userInitiated) { - try await itemActionService.createArchiveFile( + let representation = try await library.externalRepresentation( for: item, - source: source, - destinationURL: destinationURL + in: source, + preferredKind: .portablePackage + ) + return try itemActionService.persistExternalRepresentation( + representation, + to: destinationURL ) }.value @@ -703,17 +713,25 @@ struct ContentView: View { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { return } - let source = currentSource isPerformingItemAction = true Task { do { + guard let source = currentSource else { + await MainActor.run { + isPerformingItemAction = false + } + return + } + let shareURL = try await Task.detached(priority: .userInitiated) { - try await itemActionService.createArchiveFile( + let representation = try await library.externalRepresentation( for: item, - source: source + in: source, + preferredKind: .portablePackage ) + return representation.url }.value await MainActor.run { @@ -744,11 +762,6 @@ struct ContentView: View { return } - if source.origin.kind == .localFolder { - NSWorkspace.shared.activateFileViewerSelecting([item.folderURL]) - return - } - guard !isPerformingItemAction else { return } @@ -757,11 +770,15 @@ struct ContentView: View { Task { do { - let revealURL = try await library.materializeItem(item, in: source) + let representation = try await library.externalRepresentation( + for: item, + in: source, + preferredKind: .nativeFolder + ) await MainActor.run { isPerformingItemAction = false - NSWorkspace.shared.activateFileViewerSelecting([revealURL]) + NSWorkspace.shared.activateFileViewerSelecting([representation.url]) } } catch { await MainActor.run { diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index ac65960..873dba7 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -39,6 +39,64 @@ struct World_Manager_for_MinecraftTests { #expect(deviceSource.capabilities == .connectedDevice) } + @Test func libraryExternalRepresentationUsesPortablePackageByDefault() async throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true) + defer { try? fileManager.removeItem(at: rootURL) } + + try fileManager.createDirectory(at: itemURL, withIntermediateDirectories: true) + try "hello".write( + to: itemURL.appendingPathComponent("levelname.txt"), + atomically: true, + encoding: .utf8 + ) + + let item = MinecraftContentItem( + folderURL: itemURL, + folderName: "WorldA", + contentType: .world, + collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true), + displayName: "World A" + ) + let source = MinecraftSource(folderURL: rootURL) + let library = SourceLibrary() + + let representation = try await library.externalRepresentation(for: item, in: source) + + #expect(representation.kind == .portablePackage) + #expect(representation.suggestedFilename == "World A.mcworld") + #expect(representation.contentType.preferredFilenameExtension == "mcworld") + #expect(representation.isTemporary) + #expect(fileManager.fileExists(atPath: representation.url.path)) + } + + @Test func libraryExternalRepresentationUsesNativeFolderWhenRequested() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/source-root", isDirectory: true) + let itemURL = rootURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true) + let item = MinecraftContentItem( + folderURL: itemURL, + folderName: "WorldA", + contentType: .world, + collectionRootURL: rootURL.appendingPathComponent("minecraftWorlds", isDirectory: true), + displayName: "World A" + ) + let source = MinecraftSource(folderURL: rootURL) + let library = SourceLibrary() + + let representation = try await library.externalRepresentation( + for: item, + in: source, + preferredKind: .nativeFolder + ) + + #expect(representation.kind == .nativeFolder) + #expect(representation.url == itemURL) + #expect(representation.suggestedFilename == "World A") + #expect(representation.contentType == .folder) + #expect(representation.isTemporary == false) + } + @Test func packIdentityUsesUUIDAndVersion() async throws { let first = PackIdentity( type: .behaviorPack,