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 SwiftUI
|
||||
|
||||
struct SharingPickerButton: NSViewRepresentable {
|
||||
let title: String?
|
||||
struct ToolbarShareButton: View {
|
||||
let systemImage: String
|
||||
let isEnabled: Bool
|
||||
let action: (NSView) -> Void
|
||||
let action: (NSView?) -> Void
|
||||
@State private var anchorView: NSView?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(action: action)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSButton {
|
||||
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
|
||||
var body: some View {
|
||||
Button {
|
||||
action(anchorView)
|
||||
} label: {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
|
||||
@objc func didPressButton(_ sender: NSButton) {
|
||||
action(sender)
|
||||
.disabled(!isEnabled)
|
||||
.background {
|
||||
ShareAnchorView(anchorView: $anchorView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -587,6 +587,7 @@ struct ContentView: View {
|
||||
guard !isPerformingItemAction else {
|
||||
return
|
||||
}
|
||||
let source = currentSource
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
@ -605,12 +606,10 @@ struct ContentView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try ContentPackageExporter.exportItem(item, to: destinationURL)
|
||||
let finalURL = try await Task.detached(priority: .userInitiated) {
|
||||
try ContentPackageExporter.createArchiveFile(for: item, source: source, destinationURL: destinationURL)
|
||||
}.value
|
||||
|
||||
let finalURL = ContentPackageExporter.finalArchiveURL(for: item, destinationURL: destinationURL)
|
||||
|
||||
await MainActor.run {
|
||||
isPerformingItemAction = false
|
||||
library.setItemActionSuccess(
|
||||
@ -632,6 +631,7 @@ struct ContentView: View {
|
||||
guard !isPerformingItemAction else {
|
||||
return
|
||||
}
|
||||
let source = currentSource
|
||||
|
||||
isPerformingItemAction = true
|
||||
library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...")
|
||||
@ -639,7 +639,7 @@ struct ContentView: View {
|
||||
Task {
|
||||
do {
|
||||
let shareURL = try await Task.detached(priority: .userInitiated) {
|
||||
try ContentPackageExporter.prepareShareFile(for: item)
|
||||
try ContentPackageExporter.createArchiveFile(for: item, source: source)
|
||||
}.value
|
||||
|
||||
await MainActor.run {
|
||||
|
||||
@ -53,21 +53,24 @@ struct ItemDetailColumnView: View {
|
||||
}
|
||||
.toolbar {
|
||||
if item != nil {
|
||||
ToolbarItemGroup {
|
||||
ToolbarItem {
|
||||
Button(action: exportAction) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
.disabled(isPerformingItemAction)
|
||||
.help(exportTitle ?? "Export")
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button(action: revealAction) {
|
||||
Image(systemName: "folder")
|
||||
}
|
||||
.disabled(isPerformingItemAction)
|
||||
.help("Reveal in Finder")
|
||||
}
|
||||
|
||||
SharingPickerButton(
|
||||
title: nil,
|
||||
ToolbarItem {
|
||||
ToolbarShareButton(
|
||||
systemImage: "square.and.arrow.up",
|
||||
isEnabled: !isPerformingItemAction
|
||||
) { anchorView in
|
||||
|
||||
@ -10,6 +10,7 @@ import Foundation
|
||||
struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let folderURL: URL
|
||||
var origin: MinecraftSourceOrigin
|
||||
var bookmarkData: Data?
|
||||
var displayName: String
|
||||
var displayItems: [MinecraftContentItem]
|
||||
@ -26,10 +27,15 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
var indexedDetailCount: Int
|
||||
var lastScanDate: Date?
|
||||
|
||||
init(folderURL: URL, bookmarkData: Data? = nil) {
|
||||
init(
|
||||
folderURL: URL,
|
||||
bookmarkData: Data? = nil,
|
||||
origin: MinecraftSourceOrigin? = nil
|
||||
) {
|
||||
let normalizedURL = folderURL.standardizedFileURL
|
||||
self.id = normalizedURL
|
||||
self.folderURL = normalizedURL
|
||||
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||
self.bookmarkData = bookmarkData
|
||||
self.displayName = normalizedURL.lastPathComponent
|
||||
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.
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum ContentPackageExporter {
|
||||
enum ExportError: LocalizedError {
|
||||
case failedToCreateArchive(String)
|
||||
case failedToPrepareArchiveContents(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToCreateArchive(let 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 archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL)
|
||||
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
|
||||
let archiveURL: URL
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: temporaryArchiveURL)
|
||||
if let destinationURL {
|
||||
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) {
|
||||
try fileManager.removeItem(at: archiveURL)
|
||||
}
|
||||
|
||||
try fileManager.moveItem(at: temporaryArchiveURL, to: 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)
|
||||
try createArchive(for: item, source: source, at: archiveURL)
|
||||
return archiveURL
|
||||
}
|
||||
|
||||
@ -67,10 +57,25 @@ enum ContentPackageExporter {
|
||||
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()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||
process.currentDirectoryURL = item.folderURL
|
||||
process.currentDirectoryURL = stagingDirectoryURL
|
||||
process.arguments = [
|
||||
"-c",
|
||||
"-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 {
|
||||
let normalizedDestinationURL = destinationURL.standardizedFileURL
|
||||
let requiredExtension = item.contentType.archiveExtension
|
||||
@ -106,12 +240,6 @@ enum ContentPackageExporter {
|
||||
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(
|
||||
in directoryURL: URL,
|
||||
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 footerResetTask: Task<Void, Never>?
|
||||
private let persistenceStore: SourcePersistenceStore
|
||||
private let scanRootPreparer: SourceScanRootPreparing
|
||||
|
||||
init(persistenceStore: SourcePersistenceStore = .shared) {
|
||||
init(
|
||||
persistenceStore: SourcePersistenceStore = .shared,
|
||||
scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer()
|
||||
) {
|
||||
self.persistenceStore = persistenceStore
|
||||
self.scanRootPreparer = scanRootPreparer
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.restorePersistedSources()
|
||||
@ -162,12 +167,27 @@ final class SourceLibrary: ObservableObject {
|
||||
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()
|
||||
defer {
|
||||
if accessedSecurityScope {
|
||||
scanRootURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
cleanupPreparedScanRoot(preparedScanRoot)
|
||||
}
|
||||
|
||||
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 {
|
||||
contentType == .behaviorPack || contentType == .resourcePack
|
||||
}
|
||||
|
||||
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
|
||||
switch preparedScanRoot.cleanupBehavior {
|
||||
case .none:
|
||||
return
|
||||
case .unmount:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSidebarFooterState() {
|
||||
if isRestoringPersistedSources {
|
||||
cancelFooterReset()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user