Scaffolding for additional source types, some fixes for share sheet

This commit is contained in:
John Burwell 2026-05-26 13:16:47 -05:00
parent 2886d14178
commit b2858b8ff6
8 changed files with 386 additions and 110 deletions

View File

@ -1,58 +1,21 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
struct SharingPickerButton: NSViewRepresentable { struct ToolbarShareButton: View {
let title: String?
let systemImage: String let systemImage: String
let isEnabled: Bool let isEnabled: Bool
let action: (NSView) -> Void let action: (NSView?) -> Void
@State private var anchorView: NSView?
func makeCoordinator() -> Coordinator { var body: some View {
Coordinator(action: action) Button {
} action(anchorView)
} label: {
func makeNSView(context: Context) -> NSButton { Image(systemName: systemImage)
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
} }
.disabled(!isEnabled)
@objc func didPressButton(_ sender: NSButton) { .background {
action(sender) ShareAnchorView(anchorView: $anchorView)
} }
} }
} }

View File

@ -587,6 +587,7 @@ struct ContentView: View {
guard !isPerformingItemAction else { guard !isPerformingItemAction else {
return return
} }
let source = currentSource
let panel = NSSavePanel() let panel = NSSavePanel()
panel.canCreateDirectories = true panel.canCreateDirectories = true
@ -605,12 +606,10 @@ struct ContentView: View {
Task { Task {
do { do {
try await Task.detached(priority: .userInitiated) { let finalURL = try await Task.detached(priority: .userInitiated) {
try ContentPackageExporter.exportItem(item, to: destinationURL) try ContentPackageExporter.createArchiveFile(for: item, source: source, destinationURL: destinationURL)
}.value }.value
let finalURL = ContentPackageExporter.finalArchiveURL(for: item, destinationURL: destinationURL)
await MainActor.run { await MainActor.run {
isPerformingItemAction = false isPerformingItemAction = false
library.setItemActionSuccess( library.setItemActionSuccess(
@ -632,6 +631,7 @@ struct ContentView: View {
guard !isPerformingItemAction else { guard !isPerformingItemAction else {
return return
} }
let source = currentSource
isPerformingItemAction = true isPerformingItemAction = true
library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...") library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...")
@ -639,7 +639,7 @@ struct ContentView: View {
Task { Task {
do { do {
let shareURL = try await Task.detached(priority: .userInitiated) { let shareURL = try await Task.detached(priority: .userInitiated) {
try ContentPackageExporter.prepareShareFile(for: item) try ContentPackageExporter.createArchiveFile(for: item, source: source)
}.value }.value
await MainActor.run { await MainActor.run {

View File

@ -53,21 +53,24 @@ struct ItemDetailColumnView: View {
} }
.toolbar { .toolbar {
if item != nil { if item != nil {
ToolbarItemGroup { ToolbarItem {
Button(action: exportAction) { Button(action: exportAction) {
Image(systemName: "arrow.down.circle") Image(systemName: "arrow.down.circle")
} }
.disabled(isPerformingItemAction) .disabled(isPerformingItemAction)
.help(exportTitle ?? "Export") .help(exportTitle ?? "Export")
}
ToolbarItem {
Button(action: revealAction) { Button(action: revealAction) {
Image(systemName: "folder") Image(systemName: "folder")
} }
.disabled(isPerformingItemAction) .disabled(isPerformingItemAction)
.help("Reveal in Finder") .help("Reveal in Finder")
}
SharingPickerButton( ToolbarItem {
title: nil, ToolbarShareButton(
systemImage: "square.and.arrow.up", systemImage: "square.and.arrow.up",
isEnabled: !isPerformingItemAction isEnabled: !isPerformingItemAction
) { anchorView in ) { anchorView in

View File

@ -10,6 +10,7 @@ import Foundation
struct MinecraftSource: Identifiable, Hashable, Sendable { struct MinecraftSource: Identifiable, Hashable, Sendable {
let id: URL let id: URL
let folderURL: URL let folderURL: URL
var origin: MinecraftSourceOrigin
var bookmarkData: Data? var bookmarkData: Data?
var displayName: String var displayName: String
var displayItems: [MinecraftContentItem] var displayItems: [MinecraftContentItem]
@ -26,10 +27,15 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var indexedDetailCount: Int var indexedDetailCount: Int
var lastScanDate: Date? var lastScanDate: Date?
init(folderURL: URL, bookmarkData: Data? = nil) { init(
folderURL: URL,
bookmarkData: Data? = nil,
origin: MinecraftSourceOrigin? = nil
) {
let normalizedURL = folderURL.standardizedFileURL let normalizedURL = folderURL.standardizedFileURL
self.id = normalizedURL self.id = normalizedURL
self.folderURL = normalizedURL self.folderURL = normalizedURL
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
self.bookmarkData = bookmarkData self.bookmarkData = bookmarkData
self.displayName = normalizedURL.lastPathComponent self.displayName = normalizedURL.lastPathComponent
self.displayItems = [] self.displayItems = []

View File

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

View File

@ -5,53 +5,43 @@
// Created by John Burwell on 2026-05-25. // Created by John Burwell on 2026-05-25.
// //
import CryptoKit
import Foundation import Foundation
enum ContentPackageExporter { enum ContentPackageExporter {
enum ExportError: LocalizedError { enum ExportError: LocalizedError {
case failedToCreateArchive(String) case failedToCreateArchive(String)
case failedToPrepareArchiveContents(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .failedToCreateArchive(let output): case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the archive file." : 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 fileManager = FileManager.default
let archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL) let archiveURL: URL
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
defer { if let destinationURL {
try? fileManager.removeItem(at: temporaryArchiveURL) 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) { if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL) try fileManager.removeItem(at: archiveURL)
} }
try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL) try createArchive(for: item, source: source, at: 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)
return archiveURL return archiveURL
} }
@ -67,10 +57,25 @@ enum ContentPackageExporter {
normalizedArchiveURL(for: item, destinationURL: destinationURL) 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() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = item.folderURL process.currentDirectoryURL = stagingDirectoryURL
process.arguments = [ process.arguments = [
"-c", "-c",
"-k", "-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 { nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension let requiredExtension = item.contentType.archiveExtension
@ -106,12 +240,6 @@ enum ContentPackageExporter {
return normalizedDestinationURL.appendingPathExtension(requiredExtension) 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( nonisolated private static func uniqueArchiveURL(
in directoryURL: URL, in directoryURL: URL,
baseName: String, baseName: String,

View File

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

View File

@ -42,9 +42,14 @@ final class SourceLibrary: ObservableObject {
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>? private var footerResetTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore private let persistenceStore: SourcePersistenceStore
private let scanRootPreparer: SourceScanRootPreparing
init(persistenceStore: SourcePersistenceStore = .shared) { init(
persistenceStore: SourcePersistenceStore = .shared,
scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer()
) {
self.persistenceStore = persistenceStore self.persistenceStore = persistenceStore
self.scanRootPreparer = scanRootPreparer
Task { [weak self] in Task { [weak self] in
await self?.restorePersistedSources() await self?.restorePersistedSources()
@ -162,12 +167,27 @@ final class SourceLibrary: ObservableObject {
return 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() let accessedSecurityScope = scanRootURL.startAccessingSecurityScopedResource()
defer { defer {
if accessedSecurityScope { if accessedSecurityScope {
scanRootURL.stopAccessingSecurityScopedResource() scanRootURL.stopAccessingSecurityScopedResource()
} }
cleanupPreparedScanRoot(preparedScanRoot)
} }
guard FileManager.default.fileExists(atPath: scanRootURL.path) else { 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 { private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool {
contentType == .behaviorPack || contentType == .resourcePack contentType == .behaviorPack || contentType == .resourcePack
} }
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
switch preparedScanRoot.cleanupBehavior {
case .none:
return
case .unmount:
return
}
}
private func refreshSidebarFooterState() { private func refreshSidebarFooterState() {
if isRestoringPersistedSources { if isRestoringPersistedSources {
cancelFooterReset() cancelFooterReset()