Unify outbound external item representations

This commit is contained in:
John Burwell 2026-05-29 16:31:57 -05:00
parent 58ed0ca7ca
commit d1c7d1de73
5 changed files with 198 additions and 15 deletions

View File

@ -1,10 +1,43 @@
import Foundation import Foundation
import UniformTypeIdentifiers
struct ContentItemActionService: Sendable { struct ContentItemActionService: Sendable {
nonisolated init() {}
nonisolated func suggestedFilename(for item: MinecraftContentItem) -> String { nonisolated func suggestedFilename(for item: MinecraftContentItem) -> String {
ContentPackageExporter.suggestedBaseFilename(for: item) 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( nonisolated func createArchiveFile(
for item: MinecraftContentItem, for item: MinecraftContentItem,
source: MinecraftSource?, source: MinecraftSource?,

View File

@ -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
}

View File

@ -8,6 +8,7 @@
import Combine import Combine
import Foundation import Foundation
import OSLog import OSLog
import UniformTypeIdentifiers
@MainActor @MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting, LocalSourceRuntimeHosting, SourceSyncRuntimeHosting { 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 sourceAccessMethod: SourceAccessMethod
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
private let notificationService: ScanNotificationServicing private let notificationService: ScanNotificationServicing
private let itemActionService: ContentItemActionService
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory() private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
var lastMatchedConnectedSourceIDs: Set<URL> = [] var lastMatchedConnectedSourceIDs: Set<URL> = []
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:] var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
@ -46,12 +48,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
persistenceStore: SourcePersistenceStore = .shared, persistenceStore: SourcePersistenceStore = .shared,
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(), sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil, connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
notificationService: ScanNotificationServicing? = nil notificationService: ScanNotificationServicing? = nil,
itemActionService: ContentItemActionService = ContentItemActionService()
) { ) {
self.persistenceStore = persistenceStore self.persistenceStore = persistenceStore
self.sourceAccessMethod = sourceAccessMethod self.sourceAccessMethod = sourceAccessMethod
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
self.notificationService = notificationService ?? ScanNotificationService.shared self.notificationService = notificationService ?? ScanNotificationService.shared
self.itemActionService = itemActionService
Task { [weak self] in Task { [weak self] in
guard let self else { guard let self else {
@ -198,6 +202,38 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
try await sourceAccessMethod.materializeItem(for: item, in: source) 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) { func removeSource(withID sourceID: URL) {
let removedSource = source(withID: sourceID) let removedSource = source(withID: sourceID)
scanTasks[sourceID]?.cancel() 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 { private func runConnectedDeviceRefreshLoop() async {
guard let connectedDeviceAccessMethod else { guard let connectedDeviceAccessMethod else {
return return

View File

@ -660,7 +660,6 @@ struct ContentView: View {
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return return
} }
let source = currentSource
let panel = NSSavePanel() let panel = NSSavePanel()
panel.canCreateDirectories = true panel.canCreateDirectories = true
@ -679,11 +678,22 @@ struct ContentView: View {
Task { Task {
do { do {
guard let source = currentSource else {
await MainActor.run {
isPerformingItemAction = false
}
return
}
let finalURL = try await Task.detached(priority: .userInitiated) { let finalURL = try await Task.detached(priority: .userInitiated) {
try await itemActionService.createArchiveFile( let representation = try await library.externalRepresentation(
for: item, for: item,
source: source, in: source,
destinationURL: destinationURL preferredKind: .portablePackage
)
return try itemActionService.persistExternalRepresentation(
representation,
to: destinationURL
) )
}.value }.value
@ -703,17 +713,25 @@ struct ContentView: View {
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return return
} }
let source = currentSource
isPerformingItemAction = true isPerformingItemAction = true
Task { Task {
do { do {
guard let source = currentSource else {
await MainActor.run {
isPerformingItemAction = false
}
return
}
let shareURL = try await Task.detached(priority: .userInitiated) { let shareURL = try await Task.detached(priority: .userInitiated) {
try await itemActionService.createArchiveFile( let representation = try await library.externalRepresentation(
for: item, for: item,
source: source in: source,
preferredKind: .portablePackage
) )
return representation.url
}.value }.value
await MainActor.run { await MainActor.run {
@ -744,11 +762,6 @@ struct ContentView: View {
return return
} }
if source.origin.kind == .localFolder {
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
return
}
guard !isPerformingItemAction else { guard !isPerformingItemAction else {
return return
} }
@ -757,11 +770,15 @@ struct ContentView: View {
Task { Task {
do { 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 { await MainActor.run {
isPerformingItemAction = false isPerformingItemAction = false
NSWorkspace.shared.activateFileViewerSelecting([revealURL]) NSWorkspace.shared.activateFileViewerSelecting([representation.url])
} }
} catch { } catch {
await MainActor.run { await MainActor.run {

View File

@ -39,6 +39,64 @@ struct World_Manager_for_MinecraftTests {
#expect(deviceSource.capabilities == .connectedDevice) #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 { @Test func packIdentityUsesUUIDAndVersion() async throws {
let first = PackIdentity( let first = PackIdentity(
type: .behaviorPack, type: .behaviorPack,