Unify outbound external item representations
This commit is contained in:
parent
58ed0ca7ca
commit
d1c7d1de73
@ -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?,
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<URL> = []
|
||||
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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user