Scaffolding for additional source types, some fixes for share sheet
This commit is contained in:
parent
2886d14178
commit
b2858b8ff6
@ -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: {
|
||||||
|
Image(systemName: systemImage)
|
||||||
}
|
}
|
||||||
|
.disabled(!isEnabled)
|
||||||
func makeNSView(context: Context) -> NSButton {
|
.background {
|
||||||
let button = NSButton()
|
ShareAnchorView(anchorView: $anchorView)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func didPressButton(_ sender: NSButton) {
|
|
||||||
action(sender)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
78
World Manager for Minecraft/Models/SourceOrigin.swift
Normal file
78
World Manager for Minecraft/Models/SourceOrigin.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user