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