Refactor, support lightweight device scanning
This commit is contained in:
parent
aca5baa155
commit
ab6661d66b
@ -19,6 +19,7 @@ struct ContentView: View {
|
||||
@State private var isPerformingItemAction = false
|
||||
@State private var isShowingDeviceSourceSheet = false
|
||||
@State private var sortMode: ItemSortMode = .name
|
||||
@State private var directoryPreviewContents: [DirectoryPreviewEntry] = []
|
||||
|
||||
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
|
||||
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
||||
@ -32,7 +33,8 @@ struct ContentView: View {
|
||||
wrappedValue: SourceLibrary(
|
||||
sourceAccessMethod: SourceAccessCoordinator(
|
||||
connectedDeviceAccess: connectedDeviceAccess
|
||||
)
|
||||
),
|
||||
connectedDeviceAccessMethod: connectedDeviceAccess
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -40,11 +42,13 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SourcesSidebarView(
|
||||
sources: library.sources,
|
||||
localSources: library.localSources,
|
||||
connectedDevices: library.connectedDevices,
|
||||
selection: $selectedSidebarSelection,
|
||||
footerState: library.sidebarFooterState,
|
||||
addSourceAction: pickFolder,
|
||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
||||
rescanSourceAction: { source in
|
||||
selectedSidebarSelection = .allContent(sourceID: source.id)
|
||||
selectedItemID = nil
|
||||
@ -54,12 +58,19 @@ struct ContentView: View {
|
||||
removeSource(source.id)
|
||||
},
|
||||
revealFooterURLAction: revealURLInFinder(_:),
|
||||
filters: sidebarFilters(for:)
|
||||
filters: sidebarFilters(for:),
|
||||
matchedSource: { entry in
|
||||
guard let sourceID = entry.matchedSourceID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.source(withID: sourceID)
|
||||
}
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||
} content: {
|
||||
ItemListColumnView(
|
||||
isEmpty: library.sources.isEmpty,
|
||||
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||
isDropTargeted: $isDropTargeted,
|
||||
selectedItemID: $selectedItemID,
|
||||
searchText: $searchText,
|
||||
@ -87,9 +98,9 @@ struct ContentView: View {
|
||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
||||
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
||||
contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [],
|
||||
contents: directoryPreviewContents,
|
||||
directoryPreviewLimit: directoryPreviewLimit,
|
||||
isEmpty: library.sources.isEmpty,
|
||||
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||
isPerformingItemAction: isPerformingItemAction,
|
||||
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
||||
exportAction: {
|
||||
@ -126,7 +137,7 @@ struct ContentView: View {
|
||||
deviceDiscoveryService: connectedDeviceAccess,
|
||||
sourceFactory: deviceSourceFactory,
|
||||
onAddSource: { source in
|
||||
let sourceID = library.addSource(source, shouldPersist: false, shouldScan: true)
|
||||
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||
selectedItemID = nil
|
||||
isShowingDeviceSourceSheet = false
|
||||
@ -141,8 +152,14 @@ struct ContentView: View {
|
||||
|
||||
self.selectedItemID = nil
|
||||
}
|
||||
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
||||
syncSelection(with: sourceIDs)
|
||||
.onChange(of: library.sources.map(\.id)) { _, _ in
|
||||
syncSelection(with: library.visibleSources.map(\.id))
|
||||
}
|
||||
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
|
||||
syncSelection(with: library.visibleSources.map(\.id))
|
||||
}
|
||||
.task(id: currentSelectedItem?.id) {
|
||||
await refreshDirectoryPreviewContents()
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +193,7 @@ struct ContentView: View {
|
||||
|
||||
private var currentSource: MinecraftSource? {
|
||||
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
||||
return library.sources.first
|
||||
return library.visibleSources.first
|
||||
}
|
||||
|
||||
return library.source(withID: sourceID)
|
||||
@ -187,7 +204,7 @@ struct ContentView: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.sources
|
||||
return library.visibleSources
|
||||
.flatMap(\.items)
|
||||
.first(where: { $0.id == selectedItemID })
|
||||
}
|
||||
@ -503,31 +520,22 @@ struct ContentView: View {
|
||||
return logicalPack.isSuspicious
|
||||
}
|
||||
|
||||
private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: item.folderURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
private func refreshDirectoryPreviewContents() async {
|
||||
guard let item = currentSelectedItem, let source = currentSource else {
|
||||
await MainActor.run {
|
||||
directoryPreviewContents = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return urls
|
||||
.map { url in
|
||||
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||
return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory)
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.isDirectory != rhs.isDirectory {
|
||||
return lhs.isDirectory && !rhs.isDirectory
|
||||
}
|
||||
let contents = (try? await library.listContents(for: item, in: source)) ?? []
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
.prefix(directoryPreviewLimit)
|
||||
.map { $0 }
|
||||
await MainActor.run {
|
||||
directoryPreviewContents = Array(contents.prefix(directoryPreviewLimit))
|
||||
}
|
||||
}
|
||||
|
||||
private func pickFolder() {
|
||||
@ -582,7 +590,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func removeSource(_ sourceID: URL) {
|
||||
let fallbackSourceID = library.sources.first(where: { $0.id != sourceID })?.id
|
||||
let fallbackSourceID = library.visibleSources.first(where: { $0.id != sourceID })?.id
|
||||
library.removeSource(withID: sourceID)
|
||||
|
||||
if selectedSidebarSelection?.sourceID == sourceID {
|
||||
@ -594,6 +602,17 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func addConnectedDeviceSource(from entry: ConnectedDeviceSidebarEntry) {
|
||||
guard let container = entry.minecraftContainer else {
|
||||
return
|
||||
}
|
||||
|
||||
let source = deviceSourceFactory.makeSource(device: entry.device, container: container)
|
||||
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||
selectedItemID = nil
|
||||
}
|
||||
|
||||
private func syncSelection(with sourceIDs: [URL]) {
|
||||
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
||||
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
|
||||
@ -602,7 +621,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
if let selectedItemID {
|
||||
let itemStillExists = library.sources
|
||||
let itemStillExists = library.visibleSources
|
||||
.flatMap(\.items)
|
||||
.contains(where: { $0.id == selectedItemID })
|
||||
|
||||
@ -636,7 +655,11 @@ struct ContentView: View {
|
||||
Task {
|
||||
do {
|
||||
let finalURL = try await Task.detached(priority: .userInitiated) {
|
||||
try ContentPackageExporter.createArchiveFile(for: item, source: source, destinationURL: destinationURL)
|
||||
try await ContentPackageExporter.createArchiveFile(
|
||||
for: item,
|
||||
source: source,
|
||||
destinationURL: destinationURL
|
||||
)
|
||||
}.value
|
||||
|
||||
await MainActor.run {
|
||||
@ -668,7 +691,10 @@ struct ContentView: View {
|
||||
Task {
|
||||
do {
|
||||
let shareURL = try await Task.detached(priority: .userInitiated) {
|
||||
try ContentPackageExporter.createArchiveFile(for: item, source: source)
|
||||
try await ContentPackageExporter.createArchiveFile(
|
||||
for: item,
|
||||
source: source
|
||||
)
|
||||
}.value
|
||||
|
||||
await MainActor.run {
|
||||
@ -703,7 +729,42 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func revealInFinder(_ item: MinecraftContentItem) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
||||
guard let source = currentSource else {
|
||||
return
|
||||
}
|
||||
|
||||
if source.origin.kind == .localFolder {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
||||
return
|
||||
}
|
||||
|
||||
guard !isPerformingItemAction else {
|
||||
return
|
||||
}
|
||||
|
||||
isPerformingItemAction = true
|
||||
library.setItemActionInProgress("Preparing item for Finder...")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let revealURL = try await library.materializeItem(item, in: source)
|
||||
|
||||
await MainActor.run {
|
||||
isPerformingItemAction = false
|
||||
NSWorkspace.shared.activateFileViewerSelecting([revealURL])
|
||||
library.setItemActionSuccess(
|
||||
title: "Prepared for Finder",
|
||||
subtitle: item.displayName,
|
||||
revealURL: revealURL
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isPerformingItemAction = false
|
||||
library.setItemActionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealURLInFinder(_ url: URL) {
|
||||
|
||||
@ -612,6 +612,10 @@ struct ItemDetailView: View {
|
||||
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
}
|
||||
|
||||
if item.sizeLoaded {
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
||||
}
|
||||
|
||||
|
||||
@ -169,6 +169,8 @@ private struct ContentRowView: View {
|
||||
let sizeText: String
|
||||
if let sizeBytes = item.sizeBytes {
|
||||
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
} else if item.sizeLoaded {
|
||||
sizeText = "Size unavailable"
|
||||
} else if item.metadataLoaded {
|
||||
sizeText = "Calculating size..."
|
||||
} else {
|
||||
|
||||
@ -11,6 +11,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
let id: URL
|
||||
let folderURL: URL
|
||||
var origin: MinecraftSourceOrigin
|
||||
var accessDescriptor: SourceAccessDescriptor
|
||||
var availability: SourceAvailability
|
||||
var bookmarkData: Data?
|
||||
var displayName: String
|
||||
var displayItems: [MinecraftContentItem]
|
||||
@ -31,12 +33,22 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
sourceID: URL? = nil,
|
||||
folderURL: URL,
|
||||
bookmarkData: Data? = nil,
|
||||
origin: MinecraftSourceOrigin? = nil
|
||||
origin: MinecraftSourceOrigin? = nil,
|
||||
accessDescriptor: SourceAccessDescriptor? = nil,
|
||||
availability: SourceAvailability = .unknown
|
||||
) {
|
||||
let normalizedFolderURL = normalizedSourceURL(folderURL)
|
||||
let resolvedOrigin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
||||
self.folderURL = normalizedFolderURL
|
||||
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||
self.origin = resolvedOrigin
|
||||
self.accessDescriptor = accessDescriptor ?? SourceAccessDescriptor(
|
||||
accessorIdentifier: resolvedOrigin.defaultAccessorIdentifier,
|
||||
kind: resolvedOrigin.kind,
|
||||
capabilities: resolvedOrigin.defaultCapabilities,
|
||||
refreshStrategy: resolvedOrigin.defaultRefreshStrategy
|
||||
)
|
||||
self.availability = availability
|
||||
self.bookmarkData = bookmarkData
|
||||
self.displayName = normalizedFolderURL.lastPathComponent
|
||||
self.displayItems = []
|
||||
@ -108,6 +120,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
.uniqued(by: \.id)
|
||||
}
|
||||
|
||||
var sourceRecord: SourceRecord {
|
||||
SourceRecord(
|
||||
id: id,
|
||||
displayName: displayName,
|
||||
rootURL: folderURL,
|
||||
origin: origin,
|
||||
accessDescriptor: accessDescriptor,
|
||||
availability: availability,
|
||||
lastRefreshDate: lastScanDate
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldIncludeAsStandalone(_ item: MinecraftContentItem) -> Bool {
|
||||
switch item.contentType {
|
||||
case .world, .behaviorPack, .resourcePack:
|
||||
|
||||
@ -51,7 +51,16 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
||||
case localFolder(bookmarkData: Data?)
|
||||
case connectedDevice(device: ConnectedDevice, container: DeviceAppContainer)
|
||||
|
||||
var kind: MinecraftSourceKind {
|
||||
nonisolated var defaultAccessorIdentifier: SourceAccessorIdentifier {
|
||||
switch self {
|
||||
case .localFolder:
|
||||
return LocalFolderSourceAccess().accessorIdentifier
|
||||
case .connectedDevice:
|
||||
return AppleMobileDeviceSourceAccess().accessorIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var kind: MinecraftSourceKind {
|
||||
switch self {
|
||||
case .localFolder:
|
||||
return .localFolder
|
||||
@ -59,22 +68,27 @@ enum MinecraftSourceOrigin: Hashable, Sendable, Codable {
|
||||
return .connectedDevice
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var defaultCapabilities: SourceCapabilities {
|
||||
switch self {
|
||||
case .localFolder:
|
||||
return .localFolder
|
||||
case .connectedDevice:
|
||||
return .connectedDevice
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var defaultRefreshStrategy: SourceRefreshStrategy {
|
||||
switch self {
|
||||
case .localFolder:
|
||||
return .eagerFullScan
|
||||
case .connectedDevice:
|
||||
return .staged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
||||
case localFolder
|
||||
case connectedDevice
|
||||
}
|
||||
|
||||
struct PreparedScanRoot: Hashable, Sendable {
|
||||
let sourceID: URL
|
||||
let rootURL: URL
|
||||
let mountPointURL: URL?
|
||||
let cleanupBehavior: CleanupBehavior
|
||||
|
||||
enum CleanupBehavior: Hashable, Sendable {
|
||||
case none
|
||||
case unmount
|
||||
case deleteTemporaryDirectory
|
||||
}
|
||||
}
|
||||
|
||||
61
World Manager for Minecraft/Models/SourceRecord.swift
Normal file
61
World Manager for Minecraft/Models/SourceRecord.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// SourceRecord.swift
|
||||
// World Manager for Minecraft
|
||||
//
|
||||
// Created by OpenAI on 2026-05-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias SourceAccessorIdentifier = String
|
||||
|
||||
enum SourceAvailability: String, Hashable, Sendable, Codable {
|
||||
case unknown
|
||||
case available
|
||||
case disconnected
|
||||
case limited
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum SourceRefreshStrategy: String, Hashable, Sendable, Codable {
|
||||
case eagerFullScan
|
||||
case staged
|
||||
}
|
||||
|
||||
struct SourceCapabilities: Hashable, Sendable, Codable {
|
||||
var supportsDirectFileAccess: Bool
|
||||
var supportsStagedRefresh: Bool
|
||||
var supportsPersistentCaching: Bool
|
||||
var supportsLazyMaterialization: Bool
|
||||
|
||||
nonisolated static let localFolder = SourceCapabilities(
|
||||
supportsDirectFileAccess: true,
|
||||
supportsStagedRefresh: false,
|
||||
supportsPersistentCaching: false,
|
||||
supportsLazyMaterialization: false
|
||||
)
|
||||
|
||||
nonisolated static let connectedDevice = SourceCapabilities(
|
||||
supportsDirectFileAccess: false,
|
||||
supportsStagedRefresh: true,
|
||||
supportsPersistentCaching: true,
|
||||
supportsLazyMaterialization: true
|
||||
)
|
||||
}
|
||||
|
||||
struct SourceAccessDescriptor: Hashable, Sendable, Codable {
|
||||
var accessorIdentifier: SourceAccessorIdentifier
|
||||
var kind: MinecraftSourceKind
|
||||
var capabilities: SourceCapabilities
|
||||
var refreshStrategy: SourceRefreshStrategy
|
||||
}
|
||||
|
||||
struct SourceRecord: Identifiable, Hashable, Sendable, Codable {
|
||||
let id: URL
|
||||
var displayName: String
|
||||
var rootURL: URL
|
||||
var origin: MinecraftSourceOrigin
|
||||
var accessDescriptor: SourceAccessDescriptor
|
||||
var availability: SourceAvailability
|
||||
var lastRefreshDate: Date?
|
||||
}
|
||||
@ -290,15 +290,18 @@ struct SidebarColumnPreviewContainer: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
SourcesSidebarView(
|
||||
sources: PreviewFixtures.allSources,
|
||||
localSources: PreviewFixtures.allSources,
|
||||
connectedDevices: [],
|
||||
selection: $selection,
|
||||
footerState: PreviewFixtures.sidebarFooter,
|
||||
addSourceAction: {},
|
||||
addDeviceSourceAction: {},
|
||||
addConnectedDeviceAction: { _ in },
|
||||
rescanSourceAction: { _ in },
|
||||
removeSourceAction: { _ in },
|
||||
revealFooterURLAction: { _ in },
|
||||
filters: PreviewFixtures.sidebarFilters(for:)
|
||||
filters: PreviewFixtures.sidebarFilters(for:),
|
||||
matchedSource: { _ in nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ enum ContentPackageExporter {
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource? = nil,
|
||||
destinationURL: URL? = nil
|
||||
) throws -> URL {
|
||||
) async throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let archiveURL: URL
|
||||
|
||||
@ -41,7 +41,7 @@ enum ContentPackageExporter {
|
||||
try fileManager.removeItem(at: archiveURL)
|
||||
}
|
||||
|
||||
try createArchive(for: item, source: source, at: archiveURL)
|
||||
try await createArchive(for: item, source: source, at: archiveURL)
|
||||
return archiveURL
|
||||
}
|
||||
|
||||
@ -61,9 +61,9 @@ enum ContentPackageExporter {
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource?,
|
||||
at archiveURL: URL
|
||||
) throws {
|
||||
) async throws {
|
||||
let fileManager = FileManager.default
|
||||
let stagingDirectoryURL = try stagedArchiveContents(for: item, source: source, fileManager: fileManager)
|
||||
let stagingDirectoryURL = try await stagedArchiveContents(for: item, source: source, fileManager: fileManager)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: stagingDirectoryURL)
|
||||
@ -104,31 +104,40 @@ enum ContentPackageExporter {
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource?,
|
||||
fileManager: FileManager
|
||||
) throws -> URL {
|
||||
) async 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]
|
||||
)
|
||||
if let source, case .connectedDevice(_, let container) = source.origin {
|
||||
try await materializeConnectedDeviceItem(
|
||||
item,
|
||||
source: source,
|
||||
container: container,
|
||||
into: stagingDirectoryURL
|
||||
)
|
||||
} else {
|
||||
let accessURL = try archiveAccessURL(for: item, source: source)
|
||||
let accessedSecurityScope = accessURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessedSecurityScope {
|
||||
accessURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
for entryURL in contents {
|
||||
let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent)
|
||||
try fileManager.copyItem(at: entryURL, to: destinationURL)
|
||||
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(
|
||||
@ -139,6 +148,40 @@ enum ContentPackageExporter {
|
||||
return stagingDirectoryURL
|
||||
}
|
||||
|
||||
nonisolated private static func materializeConnectedDeviceItem(
|
||||
_ item: MinecraftContentItem,
|
||||
source: MinecraftSource,
|
||||
container: DeviceAppContainer,
|
||||
into destinationURL: URL
|
||||
) async throws {
|
||||
let rootPath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !rootPath.isEmpty else {
|
||||
throw ExportError.failedToPrepareArchiveContents("The connected-device source is missing its Minecraft path.")
|
||||
}
|
||||
|
||||
let sourceRootPath = source.folderURL.path
|
||||
let itemPath = item.folderURL.path
|
||||
let relativeItemPath: String
|
||||
if itemPath.hasPrefix(sourceRootPath + "/") {
|
||||
relativeItemPath = String(itemPath.dropFirst(sourceRootPath.count + 1))
|
||||
} else {
|
||||
relativeItemPath = item.folderName
|
||||
}
|
||||
|
||||
let remoteItemPath = relativeItemPath
|
||||
.split(separator: "/")
|
||||
.map(String.init)
|
||||
.reduce(rootPath) { partial, component in
|
||||
NSString(string: partial).appendingPathComponent(component)
|
||||
}
|
||||
|
||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath,
|
||||
destinationDirectoryURL: destinationURL
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func shareArchiveDirectory(fileManager: FileManager) throws -> URL {
|
||||
let baseDirectoryURL = try fileManager.url(
|
||||
for: .cachesDirectory,
|
||||
|
||||
@ -76,6 +76,33 @@ actor ImageCacheStore {
|
||||
url.standardizedFileURL.path.hasPrefix(cacheDirectoryPath + "/")
|
||||
}
|
||||
|
||||
func cachedImageURL(
|
||||
forRemoteData data: Data,
|
||||
cacheKey: String,
|
||||
pathExtension: String
|
||||
) -> URL? {
|
||||
let normalizedExtension = pathExtension.isEmpty ? "img" : pathExtension
|
||||
let dataDigest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
let sourceKey = digest(for: cacheKey)
|
||||
let cachedURL = cacheDirectoryURL
|
||||
.appendingPathComponent("\(sourceKey)-\(dataDigest)", isDirectory: false)
|
||||
.appendingPathExtension(normalizedExtension)
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true)
|
||||
|
||||
if fileManager.fileExists(atPath: cachedURL.path) {
|
||||
return cachedURL
|
||||
}
|
||||
|
||||
purgeStaleVariants(forSourceKey: sourceKey, keeping: cachedURL)
|
||||
try data.write(to: cachedURL, options: .atomic)
|
||||
return cachedURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeStaleVariants(forSourceKey sourceKey: String, keeping cachedURL: URL) {
|
||||
guard let cachedFiles = try? fileManager.contentsOfDirectory(
|
||||
at: cacheDirectoryURL,
|
||||
|
||||
@ -23,13 +23,33 @@ struct SidebarFooterState {
|
||||
let revealURL: URL?
|
||||
}
|
||||
|
||||
struct ConnectedDeviceSidebarEntry: Identifiable, Hashable {
|
||||
let device: ConnectedDevice
|
||||
let containers: [DeviceAppContainer]
|
||||
let matchedSourceID: URL?
|
||||
let discoveryErrorDescription: String?
|
||||
|
||||
var id: String { device.id }
|
||||
|
||||
var minecraftContainer: DeviceAppContainer? {
|
||||
containers.first(where: { $0.appID == "com.mojang.minecraftpe" })
|
||||
?? containers.first(where: { $0.minecraftFolderRelativePath != nil })
|
||||
}
|
||||
|
||||
var hasMinecraftContainer: Bool {
|
||||
minecraftContainer != nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SourceLibrary: ObservableObject {
|
||||
private static let enrichmentWorkerCount = 4
|
||||
private static let sizeWorkerCount = 2
|
||||
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
||||
private static let connectedDeviceRefreshInterval: TimeInterval = 0.5
|
||||
|
||||
@Published var sources: [MinecraftSource] = []
|
||||
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||
@Published private(set) var sidebarFooterState = SidebarFooterState(
|
||||
style: .idle,
|
||||
title: "",
|
||||
@ -40,20 +60,47 @@ final class SourceLibrary: ObservableObject {
|
||||
@Published private(set) var isRestoringPersistedSources = true
|
||||
|
||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||
private var connectedDeviceRefreshTask: Task<Void, Never>?
|
||||
private var footerResetTask: Task<Void, Never>?
|
||||
private let persistenceStore: SourcePersistenceStore
|
||||
private let sourceAccessMethod: SourceAccessMethod
|
||||
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
||||
private var lastMatchedConnectedSourceIDs: Set<URL> = []
|
||||
|
||||
init(
|
||||
persistenceStore: SourcePersistenceStore = .shared,
|
||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess()
|
||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil
|
||||
) {
|
||||
self.persistenceStore = persistenceStore
|
||||
self.sourceAccessMethod = sourceAccessMethod
|
||||
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.restorePersistedSources()
|
||||
}
|
||||
|
||||
if connectedDeviceAccessMethod != nil {
|
||||
connectedDeviceRefreshTask = Task { [weak self] in
|
||||
await self?.runConnectedDeviceRefreshLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var visibleSources: [MinecraftSource] {
|
||||
let matchedConnectedSourceIDs = Set(connectedDevices.compactMap(\.matchedSourceID))
|
||||
return sources.filter { source in
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return true
|
||||
case .connectedDevice:
|
||||
return matchedConnectedSourceIDs.contains(source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localSources: [MinecraftSource] {
|
||||
visibleSources.filter { $0.origin.kind == .localFolder }
|
||||
}
|
||||
|
||||
func addSource(at url: URL) -> URL {
|
||||
@ -65,12 +112,22 @@ final class SourceLibrary: ObservableObject {
|
||||
if source.bookmarkData == nil {
|
||||
source.bookmarkData = bookmarkData
|
||||
}
|
||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||
}
|
||||
startScan(for: normalizedURL)
|
||||
return normalizedURL
|
||||
}
|
||||
|
||||
let source = MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)
|
||||
let source = MinecraftSource(
|
||||
folderURL: normalizedURL,
|
||||
bookmarkData: bookmarkData,
|
||||
accessDescriptor: SourceAccessDescriptor(
|
||||
accessorIdentifier: LocalFolderSourceAccess().accessorIdentifier,
|
||||
kind: .localFolder,
|
||||
capabilities: .localFolder,
|
||||
refreshStrategy: .eagerFullScan
|
||||
)
|
||||
)
|
||||
return addSource(source, shouldPersist: true, shouldScan: true)
|
||||
}
|
||||
|
||||
@ -79,6 +136,8 @@ final class SourceLibrary: ObservableObject {
|
||||
if sources.contains(where: { $0.id == source.id }) {
|
||||
updateSource(source.id) { existingSource in
|
||||
existingSource.origin = source.origin
|
||||
existingSource.accessDescriptor = source.accessDescriptor
|
||||
existingSource.availability = source.availability
|
||||
if existingSource.bookmarkData == nil {
|
||||
existingSource.bookmarkData = source.bookmarkData
|
||||
}
|
||||
@ -87,11 +146,13 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sources.append(source)
|
||||
var resolvedSource = source
|
||||
resolvedSource.accessDescriptor = sourceAccessMethod.accessDescriptor(for: resolvedSource)
|
||||
sources.append(resolvedSource)
|
||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
|
||||
if shouldPersist, source.origin.kind == .localFolder {
|
||||
if shouldPersist {
|
||||
persistSourceIfAvailable(withID: source.id)
|
||||
}
|
||||
if shouldScan {
|
||||
@ -109,11 +170,23 @@ final class SourceLibrary: ObservableObject {
|
||||
startScan(for: sourceID)
|
||||
}
|
||||
|
||||
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||
try await sourceAccessMethod.listItemContents(for: item, in: source)
|
||||
}
|
||||
|
||||
func materializeItem(_ item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||
try await sourceAccessMethod.materializeItem(for: item, in: source)
|
||||
}
|
||||
|
||||
func removeSource(withID sourceID: URL) {
|
||||
let removedSource = source(withID: sourceID)
|
||||
scanTasks[sourceID]?.cancel()
|
||||
scanTasks[sourceID] = nil
|
||||
sources.removeAll { $0.id == sourceID }
|
||||
deletePersistedSource(withID: sourceID)
|
||||
if let removedSource {
|
||||
purgeCachedArtifacts(for: removedSource)
|
||||
}
|
||||
refreshSidebarFooterState()
|
||||
}
|
||||
|
||||
@ -191,45 +264,10 @@ final class SourceLibrary: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let preparedScanRoot: PreparedScanRoot
|
||||
do {
|
||||
preparedScanRoot = try await sourceAccessMethod.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 {
|
||||
updateSource(sourceID) { source in
|
||||
source.scanError = "Source folder is no longer available."
|
||||
source.scanStatus = ""
|
||||
source.isScanning = false
|
||||
}
|
||||
refreshSidebarFooterState()
|
||||
return
|
||||
}
|
||||
|
||||
await WorldScanner.beginScanSession(for: sourceID)
|
||||
|
||||
updateSource(sourceID) { source in
|
||||
source.isScanning = true
|
||||
source.scanError = nil
|
||||
source.scanStatus = "Scanning Minecraft library..."
|
||||
source.scanStatus = initialScanStatus(for: source)
|
||||
source.displayItems = []
|
||||
source.rawItems = []
|
||||
source.logicalPacks = []
|
||||
@ -242,8 +280,30 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
refreshSidebarFooterState()
|
||||
|
||||
updateSource(sourceID) { source in
|
||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||
}
|
||||
let currentAvailability = await sourceAccessMethod.availability(for: source)
|
||||
updateSource(sourceID) { source in
|
||||
source.availability = currentAvailability
|
||||
}
|
||||
|
||||
let scanContextURL = source.folderURL
|
||||
await WorldScanner.beginScanSession(for: scanContextURL)
|
||||
defer {
|
||||
Task.detached(priority: .utility) {
|
||||
await WorldScanner.endScanSession(for: scanContextURL)
|
||||
}
|
||||
}
|
||||
|
||||
updateSource(sourceID) { source in
|
||||
source.availability = .available
|
||||
source.scanStatus = scanningLibraryStatus(for: source)
|
||||
}
|
||||
refreshSidebarFooterState()
|
||||
|
||||
do {
|
||||
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanRootURL)
|
||||
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
|
||||
let enrichmentQueue = EnrichmentWorkQueue()
|
||||
let sizeQueue = EnrichmentWorkQueue()
|
||||
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in
|
||||
@ -257,7 +317,7 @@ final class SourceLibrary: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let enrichedItem = await WorldScanner.enrich(item: item)
|
||||
let enrichedItem = await library.sourceAccessMethod.enrich(item, for: source)
|
||||
if let snapshot = await index.applyEnrichedItem(enrichedItem) {
|
||||
await MainActor.run {
|
||||
library.applySnapshot(snapshot, to: sourceID)
|
||||
@ -279,7 +339,7 @@ final class SourceLibrary: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let sizedItem = WorldScanner.loadSize(for: item)
|
||||
let sizedItem = await library.sourceAccessMethod.loadSize(for: item, in: source)
|
||||
if let snapshot = await index.applySizedItem(sizedItem) {
|
||||
await MainActor.run {
|
||||
library.applySnapshot(snapshot, to: sourceID)
|
||||
@ -290,9 +350,10 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
}
|
||||
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
|
||||
let accessMethod = sourceAccessMethod
|
||||
let discoveryTask = Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
_ = try WorldScanner.discoverItems(in: scanRootURL) { item in
|
||||
_ = try await accessMethod.discoverItems(for: source) { item in
|
||||
continuation.yield(item)
|
||||
}
|
||||
continuation.finish()
|
||||
@ -353,7 +414,11 @@ final class SourceLibrary: ObservableObject {
|
||||
applySnapshot(snapshot, to: sourceID)
|
||||
}
|
||||
updateSource(sourceID) { source in
|
||||
source.snapshot = buildSnapshot(for: source, packMetadataByItemID: [:])
|
||||
if source.origin.kind == .localFolder {
|
||||
source.snapshot = buildSnapshot(for: source, scanRootURL: scanContextURL, packMetadataByItemID: [:])
|
||||
} else {
|
||||
source.snapshot = nil
|
||||
}
|
||||
}
|
||||
persistSourceIfAvailable(withID: sourceID)
|
||||
refreshSidebarFooterState()
|
||||
@ -363,10 +428,12 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
updateSource(sourceID) { source in
|
||||
source.availability = availabilityStatus(for: error, defaultingTo: source.availability)
|
||||
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||
source.scanStatus = ""
|
||||
source.isScanning = false
|
||||
}
|
||||
persistSourceIfAvailable(withID: sourceID)
|
||||
refreshSidebarFooterState()
|
||||
}
|
||||
}
|
||||
@ -751,6 +818,214 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func runConnectedDeviceRefreshLoop() async {
|
||||
while !Task.isCancelled {
|
||||
await refreshConnectedDevices()
|
||||
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshInterval))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshConnectedDevices() async {
|
||||
guard let connectedDeviceAccessMethod else {
|
||||
return
|
||||
}
|
||||
|
||||
let devices: [ConnectedDevice]
|
||||
do {
|
||||
devices = try await connectedDeviceAccessMethod.listConnectedDevices()
|
||||
} catch {
|
||||
markAllConnectedDeviceSourcesDisconnected()
|
||||
connectedDevices = []
|
||||
lastMatchedConnectedSourceIDs = []
|
||||
return
|
||||
}
|
||||
|
||||
var entries: [ConnectedDeviceSidebarEntry] = []
|
||||
var matchedSourceIDs = Set<URL>()
|
||||
|
||||
for device in devices {
|
||||
if let matchedSourceID = knownConnectedDeviceSourceID(for: device) {
|
||||
matchedSourceIDs.insert(matchedSourceID)
|
||||
refreshMatchedConnectedDeviceSource(
|
||||
sourceID: matchedSourceID,
|
||||
device: device,
|
||||
containers: []
|
||||
)
|
||||
|
||||
entries.append(
|
||||
ConnectedDeviceSidebarEntry(
|
||||
device: device,
|
||||
containers: [],
|
||||
matchedSourceID: matchedSourceID,
|
||||
discoveryErrorDescription: nil
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let containers: [DeviceAppContainer]
|
||||
let discoveryErrorDescription: String?
|
||||
|
||||
do {
|
||||
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
|
||||
discoveryErrorDescription = nil
|
||||
} catch {
|
||||
containers = []
|
||||
discoveryErrorDescription = error.localizedDescription
|
||||
}
|
||||
|
||||
let matchedSourceID = matchingConnectedDeviceSourceID(
|
||||
device: device,
|
||||
containers: containers
|
||||
)
|
||||
|
||||
if let matchedSourceID {
|
||||
matchedSourceIDs.insert(matchedSourceID)
|
||||
refreshMatchedConnectedDeviceSource(
|
||||
sourceID: matchedSourceID,
|
||||
device: device,
|
||||
containers: containers
|
||||
)
|
||||
}
|
||||
|
||||
let shouldDisplayEntry =
|
||||
matchedSourceID != nil
|
||||
|| !containers.isEmpty
|
||||
|| device.trustState != .trusted
|
||||
|
||||
if shouldDisplayEntry {
|
||||
entries.append(
|
||||
ConnectedDeviceSidebarEntry(
|
||||
device: device,
|
||||
containers: containers,
|
||||
matchedSourceID: matchedSourceID,
|
||||
discoveryErrorDescription: discoveryErrorDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
markDisconnectedConnectedDeviceSources(excluding: matchedSourceIDs)
|
||||
|
||||
connectedDevices = entries.sorted {
|
||||
let lhsKnown = $0.matchedSourceID != nil
|
||||
let rhsKnown = $1.matchedSourceID != nil
|
||||
if lhsKnown != rhsKnown {
|
||||
return lhsKnown && !rhsKnown
|
||||
}
|
||||
|
||||
let lhsMinecraft = $0.hasMinecraftContainer
|
||||
let rhsMinecraft = $1.hasMinecraftContainer
|
||||
if lhsMinecraft != rhsMinecraft {
|
||||
return lhsMinecraft && !rhsMinecraft
|
||||
}
|
||||
|
||||
return $0.device.name.localizedStandardCompare($1.device.name) == .orderedAscending
|
||||
}
|
||||
|
||||
lastMatchedConnectedSourceIDs = matchedSourceIDs
|
||||
}
|
||||
|
||||
private func matchingConnectedDeviceSourceID(
|
||||
device: ConnectedDevice,
|
||||
containers: [DeviceAppContainer]
|
||||
) -> URL? {
|
||||
for source in sources {
|
||||
guard case .connectedDevice(let expectedDevice, let expectedContainer) = source.origin else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard expectedDevice.udid == device.udid else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard containers.contains(where: { container in
|
||||
container.appID == expectedContainer.appID
|
||||
&& container.accessMode == expectedContainer.accessMode
|
||||
}) else {
|
||||
continue
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? {
|
||||
for source in sources {
|
||||
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard expectedDevice.udid == device.udid else {
|
||||
continue
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func refreshMatchedConnectedDeviceSource(
|
||||
sourceID: URL,
|
||||
device: ConnectedDevice,
|
||||
containers: [DeviceAppContainer]
|
||||
) {
|
||||
updateSource(sourceID) { source in
|
||||
guard case .connectedDevice(_, let previousContainer) = source.origin else {
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedContainer = containers.first(where: {
|
||||
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
||||
}) ?? previousContainer
|
||||
|
||||
source.origin = .connectedDevice(device: device, container: resolvedContainer)
|
||||
source.displayName = "\(device.name) • \(resolvedContainer.appName)"
|
||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
||||
source.availability = availability(for: device, hasMinecraftContainer: true)
|
||||
}
|
||||
persistSourceIfAvailable(withID: sourceID)
|
||||
}
|
||||
|
||||
private func markAllConnectedDeviceSourcesDisconnected() {
|
||||
for source in sources where source.origin.kind == .connectedDevice {
|
||||
updateSource(source.id) { source in
|
||||
source.availability = .disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markDisconnectedConnectedDeviceSources(excluding matchedSourceIDs: Set<URL>) {
|
||||
for source in sources where source.origin.kind == .connectedDevice && !matchedSourceIDs.contains(source.id) {
|
||||
updateSource(source.id) { source in
|
||||
source.availability = .disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func availability(for device: ConnectedDevice, hasMinecraftContainer: Bool) -> SourceAvailability {
|
||||
guard hasMinecraftContainer else {
|
||||
return .unavailable
|
||||
}
|
||||
|
||||
switch device.trustState {
|
||||
case .trusted:
|
||||
return .available
|
||||
case .locked, .untrusted:
|
||||
return .limited
|
||||
case .unavailable:
|
||||
return .disconnected
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePersistedSources() async {
|
||||
defer {
|
||||
isRestoringPersistedSources = false
|
||||
@ -765,7 +1040,14 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
for record in records {
|
||||
var source = MinecraftSource(folderURL: record.folderURL, bookmarkData: record.bookmarkData)
|
||||
var source = MinecraftSource(
|
||||
sourceID: record.sourceID,
|
||||
folderURL: record.folderURL,
|
||||
bookmarkData: record.bookmarkData,
|
||||
origin: record.origin,
|
||||
accessDescriptor: record.accessDescriptor,
|
||||
availability: record.availability
|
||||
)
|
||||
source.displayName = record.displayName
|
||||
source.rawItems = await restoreCachedImages(in: record.rawItems)
|
||||
source.indexedItemCount = record.rawItems.count
|
||||
@ -788,9 +1070,11 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
for record in records {
|
||||
if sourceNeedsRescan(record) {
|
||||
startScan(for: record.folderURL)
|
||||
startScan(for: record.sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
await refreshConnectedDevices()
|
||||
}
|
||||
|
||||
private func restoreCachedImages(in items: [MinecraftContentItem]) async -> [MinecraftContentItem] {
|
||||
@ -828,6 +1112,10 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func sourceNeedsRescan(_ record: PersistedSourceRecord) -> Bool {
|
||||
guard record.accessDescriptor.refreshStrategy == .eagerFullScan else {
|
||||
return record.rawItems.isEmpty
|
||||
}
|
||||
|
||||
guard let snapshot = record.snapshot else {
|
||||
return true
|
||||
}
|
||||
@ -949,9 +1237,14 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func deletePersistedSource(withID sourceID: URL) {
|
||||
let normalizedSourceID = sourceID.standardizedFileURL
|
||||
Task {
|
||||
try? await persistenceStore.deleteSource(withID: normalizedSourceID)
|
||||
try? await persistenceStore.deleteSource(withID: sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeCachedArtifacts(for source: MinecraftSource) {
|
||||
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
||||
await sourceAccessMethod.purgeCachedArtifacts(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
@ -967,12 +1260,6 @@ final class SourceLibrary: ObservableObject {
|
||||
contentType == .behaviorPack || contentType == .resourcePack
|
||||
}
|
||||
|
||||
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
|
||||
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
||||
await sourceAccessMethod.releaseScanRoot(preparedScanRoot)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSidebarFooterState() {
|
||||
if isRestoringPersistedSources {
|
||||
cancelFooterReset()
|
||||
@ -1030,6 +1317,24 @@ final class SourceLibrary: ObservableObject {
|
||||
footerResetTask = nil
|
||||
}
|
||||
|
||||
private func initialScanStatus(for source: MinecraftSource) -> String {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return "Preparing folder scan..."
|
||||
case .connectedDevice:
|
||||
return "Connecting to device and discovering Minecraft items..."
|
||||
}
|
||||
}
|
||||
|
||||
private func scanningLibraryStatus(for source: MinecraftSource) -> String {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return "Scanning Minecraft library..."
|
||||
case .connectedDevice:
|
||||
return "Scanning Minecraft library on device..."
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFooterReset(after seconds: Double = 5) {
|
||||
cancelFooterReset()
|
||||
footerResetTask = Task { @MainActor [weak self] in
|
||||
@ -1044,10 +1349,11 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
private func buildSnapshot(
|
||||
for source: MinecraftSource,
|
||||
scanRootURL: URL,
|
||||
packMetadataByItemID: [URL: PackMetadata]
|
||||
) -> SourceSnapshot {
|
||||
let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in
|
||||
let collectionURL = source.folderURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||
let collectionURL = scanRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||
guard FileManager.default.fileExists(atPath: collectionURL.path) else {
|
||||
return nil
|
||||
}
|
||||
@ -1075,7 +1381,7 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
let itemSnapshots = source.rawItems.map { item in
|
||||
let relativePath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "")
|
||||
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
|
||||
let metadata = packMetadataByItemID[item.id]
|
||||
return ItemSnapshot(
|
||||
id: item.id,
|
||||
@ -1089,7 +1395,7 @@ final class SourceLibrary: ObservableObject {
|
||||
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
|
||||
}
|
||||
|
||||
let rootModifiedDate = try? source.folderURL
|
||||
let rootModifiedDate = try? scanRootURL
|
||||
.resourceValues(forKeys: [.contentModificationDateKey])
|
||||
.contentModificationDate
|
||||
|
||||
@ -1101,6 +1407,21 @@ final class SourceLibrary: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func availabilityStatus(for error: Error, defaultingTo currentAvailability: SourceAvailability) -> SourceAvailability {
|
||||
if let accessError = error as? SourceAccessError {
|
||||
switch accessError {
|
||||
case .deviceUnavailable:
|
||||
return .disconnected
|
||||
case .deviceNotTrusted:
|
||||
return .limited
|
||||
case .appNotAccessible, .minecraftFolderMissing, .accessFailed:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
return currentAvailability
|
||||
}
|
||||
|
||||
private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
||||
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
||||
let existingEmbedded = isEmbeddedWorldPack(existing)
|
||||
|
||||
@ -9,7 +9,11 @@ import Foundation
|
||||
import SQLite3
|
||||
|
||||
struct PersistedSourceRecord: Sendable {
|
||||
let sourceID: URL
|
||||
let folderURL: URL
|
||||
let origin: MinecraftSourceOrigin
|
||||
let accessDescriptor: SourceAccessDescriptor
|
||||
let availability: SourceAvailability
|
||||
let bookmarkData: Data?
|
||||
let displayName: String
|
||||
let rawItems: [MinecraftContentItem]
|
||||
@ -122,13 +126,13 @@ private struct PersistedCollectionSnapshotPayload: Codable, Sendable {
|
||||
}
|
||||
|
||||
private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
||||
let sourcePath: String
|
||||
let sourceIdentifier: String
|
||||
let rootModifiedDate: Date?
|
||||
let collectionSnapshots: [PersistedCollectionSnapshotPayload]
|
||||
let itemSnapshots: [PersistedItemSnapshotPayload]
|
||||
|
||||
nonisolated init(_ snapshot: SourceSnapshot) {
|
||||
self.sourcePath = snapshot.sourceID.path
|
||||
self.sourceIdentifier = snapshot.sourceID.absoluteString
|
||||
self.rootModifiedDate = snapshot.rootModifiedDate
|
||||
self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
|
||||
self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
|
||||
@ -136,7 +140,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
||||
|
||||
nonisolated var sourceSnapshot: SourceSnapshot {
|
||||
SourceSnapshot(
|
||||
sourceID: URL(fileURLWithPath: sourcePath),
|
||||
sourceID: URL(string: sourceIdentifier) ?? URL(fileURLWithPath: sourceIdentifier),
|
||||
rootModifiedDate: rootModifiedDate,
|
||||
collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot),
|
||||
itemSnapshots: itemSnapshots.map(\.itemSnapshot)
|
||||
@ -145,7 +149,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.sourcePath = try container.decode(String.self, forKey: .sourcePath)
|
||||
self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier)
|
||||
self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
|
||||
self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
|
||||
self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
|
||||
@ -153,14 +157,14 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(sourcePath, forKey: .sourcePath)
|
||||
try container.encode(sourceIdentifier, forKey: .sourceIdentifier)
|
||||
try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
|
||||
try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
|
||||
try container.encode(itemSnapshots, forKey: .itemSnapshots)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sourcePath
|
||||
case sourceIdentifier
|
||||
case rootModifiedDate
|
||||
case collectionSnapshots
|
||||
case itemSnapshots
|
||||
@ -189,7 +193,8 @@ actor SourcePersistenceStore {
|
||||
defer { sqlite3_close(database) }
|
||||
|
||||
let sql = """
|
||||
SELECT folder_path, bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
|
||||
SELECT source_id, folder_path, origin_json, access_descriptor_json, availability_state,
|
||||
bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
|
||||
FROM source_cache
|
||||
ORDER BY display_name COLLATE NOCASE ASC;
|
||||
"""
|
||||
@ -203,23 +208,38 @@ actor SourcePersistenceStore {
|
||||
var records: [PersistedSourceRecord] = []
|
||||
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let folderPathPointer = sqlite3_column_text(statement, 0) else {
|
||||
guard let folderPathPointer = sqlite3_column_text(statement, 1) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
|
||||
let folderPath = String(cString: folderPathPointer)
|
||||
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 1)
|
||||
let displayName = String(cString: sqlite3_column_text(statement, 2))
|
||||
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 3) ?? []
|
||||
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 4)
|
||||
let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: 2)
|
||||
?? .localFolder(bookmarkData: nil)
|
||||
let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: 3)
|
||||
?? SourceAccessDescriptor(
|
||||
accessorIdentifier: origin.defaultAccessorIdentifier,
|
||||
kind: origin.kind,
|
||||
capabilities: origin.defaultCapabilities,
|
||||
refreshStrategy: origin.defaultRefreshStrategy
|
||||
)
|
||||
let availability = decodeAvailability(statement: statement, columnIndex: 4)
|
||||
let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
|
||||
let displayName = String(cString: sqlite3_column_text(statement, 6))
|
||||
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 7) ?? []
|
||||
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 8)
|
||||
let snapshot = snapshotPayload?.sourceSnapshot
|
||||
let lastScanDate = sqlite3_column_type(statement, 5) == SQLITE_NULL
|
||||
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
|
||||
? nil
|
||||
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 5))
|
||||
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
|
||||
|
||||
records.append(
|
||||
PersistedSourceRecord(
|
||||
sourceID: sourceID,
|
||||
folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL,
|
||||
origin: origin,
|
||||
accessDescriptor: accessDescriptor,
|
||||
availability: availability,
|
||||
bookmarkData: bookmarkData,
|
||||
displayName: displayName,
|
||||
rawItems: rawItems,
|
||||
@ -238,15 +258,23 @@ actor SourcePersistenceStore {
|
||||
|
||||
let sql = """
|
||||
INSERT INTO source_cache (
|
||||
source_id,
|
||||
folder_path,
|
||||
origin_json,
|
||||
access_descriptor_json,
|
||||
availability_state,
|
||||
bookmark_data,
|
||||
display_name,
|
||||
raw_items_json,
|
||||
snapshot_json,
|
||||
last_scan_date
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(folder_path) DO UPDATE SET
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source_id) DO UPDATE SET
|
||||
bookmark_data = excluded.bookmark_data,
|
||||
folder_path = excluded.folder_path,
|
||||
origin_json = excluded.origin_json,
|
||||
access_descriptor_json = excluded.access_descriptor_json,
|
||||
availability_state = excluded.availability_state,
|
||||
display_name = excluded.display_name,
|
||||
raw_items_json = excluded.raw_items_json,
|
||||
snapshot_json = excluded.snapshot_json,
|
||||
@ -259,16 +287,20 @@ actor SourcePersistenceStore {
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
try bindText(source.folderURL.path, to: statement, at: 1)
|
||||
try bindData(source.bookmarkData, to: statement, at: 2)
|
||||
try bindText(source.displayName, to: statement, at: 3)
|
||||
try bindJSON(source.rawItems, to: statement, at: 4)
|
||||
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 5)
|
||||
try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1)
|
||||
try bindText(source.folderURL.path, to: statement, at: 2)
|
||||
try bindJSON(source.origin, to: statement, at: 3)
|
||||
try bindJSON(source.accessDescriptor, to: statement, at: 4)
|
||||
try bindText(source.availability.rawValue, to: statement, at: 5)
|
||||
try bindData(source.bookmarkData, to: statement, at: 6)
|
||||
try bindText(source.displayName, to: statement, at: 7)
|
||||
try bindJSON(source.rawItems, to: statement, at: 8)
|
||||
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)
|
||||
|
||||
if let lastScanDate = source.lastScanDate {
|
||||
sqlite3_bind_double(statement, 6, lastScanDate.timeIntervalSince1970)
|
||||
sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 6)
|
||||
sqlite3_bind_null(statement, 10)
|
||||
}
|
||||
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
@ -280,14 +312,15 @@ actor SourcePersistenceStore {
|
||||
let database = try openDatabase()
|
||||
defer { sqlite3_close(database) }
|
||||
|
||||
let sql = "DELETE FROM source_cache WHERE folder_path = ?;"
|
||||
let sql = "DELETE FROM source_cache WHERE source_id = ? OR folder_path = ?;"
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
throw databaseError(database)
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
try bindText(sourceID.standardizedFileURL.path, to: statement, at: 1)
|
||||
try bindText(normalizedIdentifierText(for: sourceID), to: statement, at: 1)
|
||||
try bindText(sourceID.isFileURL ? sourceID.standardizedFileURL.path : sourceID.path, to: statement, at: 2)
|
||||
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw databaseError(database)
|
||||
@ -309,7 +342,11 @@ actor SourcePersistenceStore {
|
||||
try execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS source_cache (
|
||||
source_id TEXT,
|
||||
folder_path TEXT PRIMARY KEY,
|
||||
origin_json BLOB,
|
||||
access_descriptor_json BLOB,
|
||||
availability_state TEXT,
|
||||
bookmark_data BLOB,
|
||||
display_name TEXT NOT NULL,
|
||||
raw_items_json BLOB NOT NULL,
|
||||
@ -319,15 +356,59 @@ actor SourcePersistenceStore {
|
||||
""",
|
||||
on: database
|
||||
)
|
||||
let existingColumns = try columns(in: "source_cache", on: database)
|
||||
try addColumnIfNeeded("bookmark_data", sql: "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", existingColumns: existingColumns, on: database)
|
||||
try addColumnIfNeeded("source_id", sql: "ALTER TABLE source_cache ADD COLUMN source_id TEXT;", existingColumns: existingColumns, on: database)
|
||||
try addColumnIfNeeded("origin_json", sql: "ALTER TABLE source_cache ADD COLUMN origin_json BLOB;", existingColumns: existingColumns, on: database)
|
||||
try addColumnIfNeeded("access_descriptor_json", sql: "ALTER TABLE source_cache ADD COLUMN access_descriptor_json BLOB;", existingColumns: existingColumns, on: database)
|
||||
try addColumnIfNeeded("availability_state", sql: "ALTER TABLE source_cache ADD COLUMN availability_state TEXT;", existingColumns: existingColumns, on: database)
|
||||
try execute(
|
||||
"ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;",
|
||||
on: database,
|
||||
ignoringDuplicateColumn: true
|
||||
"""
|
||||
UPDATE source_cache
|
||||
SET source_id = folder_path
|
||||
WHERE source_id IS NULL OR source_id = '';
|
||||
""",
|
||||
on: database
|
||||
)
|
||||
try execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS source_cache_source_id_idx ON source_cache(source_id);",
|
||||
on: database
|
||||
)
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
private func columns(in tableName: String, on database: OpaquePointer?) throws -> Set<String> {
|
||||
let sql = "PRAGMA table_info(\(tableName));"
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
throw databaseError(database)
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
var columns = Set<String>()
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
if let namePointer = sqlite3_column_text(statement, 1) {
|
||||
columns.insert(String(cString: namePointer))
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
private func addColumnIfNeeded(
|
||||
_ columnName: String,
|
||||
sql: String,
|
||||
existingColumns: Set<String>,
|
||||
on database: OpaquePointer?
|
||||
) throws {
|
||||
guard !existingColumns.contains(columnName) else {
|
||||
return
|
||||
}
|
||||
|
||||
try execute(sql, on: database)
|
||||
}
|
||||
|
||||
private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws {
|
||||
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||||
if ignoringDuplicateColumn,
|
||||
@ -401,6 +482,34 @@ actor SourcePersistenceStore {
|
||||
return Data(bytes: bytes, count: byteCount)
|
||||
}
|
||||
|
||||
private func sourceID(from statement: OpaquePointer?) -> URL? {
|
||||
guard let pointer = sqlite3_column_text(statement, 0) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = String(cString: pointer)
|
||||
return URL(string: value) ?? URL(fileURLWithPath: value)
|
||||
}
|
||||
|
||||
private func decodeAvailability(statement: OpaquePointer?, columnIndex: Int32) -> SourceAvailability {
|
||||
guard
|
||||
let pointer = sqlite3_column_text(statement, columnIndex),
|
||||
let availability = SourceAvailability(rawValue: String(cString: pointer))
|
||||
else {
|
||||
return .unknown
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
private func normalizedIdentifierText(for sourceID: URL) -> String {
|
||||
if sourceID.isFileURL {
|
||||
return sourceID.standardizedFileURL.absoluteString
|
||||
}
|
||||
|
||||
return sourceID.standardized.absoluteString
|
||||
}
|
||||
|
||||
private func databaseError(_ database: OpaquePointer?) -> Error {
|
||||
persistenceError(String(cString: sqlite3_errmsg(database)))
|
||||
}
|
||||
|
||||
@ -21,42 +21,39 @@ struct SidebarFilter: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
struct SourcesSidebarView: View {
|
||||
let sources: [MinecraftSource]
|
||||
let localSources: [MinecraftSource]
|
||||
let connectedDevices: [ConnectedDeviceSidebarEntry]
|
||||
@Binding var selection: SidebarSelection?
|
||||
let footerState: SidebarFooterState
|
||||
let addSourceAction: () -> Void
|
||||
let addDeviceSourceAction: () -> Void
|
||||
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
|
||||
let rescanSourceAction: (MinecraftSource) -> Void
|
||||
let removeSourceAction: (MinecraftSource) -> Void
|
||||
let revealFooterURLAction: (URL) -> Void
|
||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||
let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource?
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section {
|
||||
ForEach(sources) { source in
|
||||
SourceHeaderRow(title: source.displayName)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
.contextMenu {
|
||||
Button("Rescan \"\(source.displayName)\"") {
|
||||
rescanSourceAction(source)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||
removeSourceAction(source)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(filters(source)) { filter in
|
||||
SidebarFilterRow(filter: filter, isIndented: true)
|
||||
.tag(filter.selection as SidebarSelection?)
|
||||
if !localSources.isEmpty {
|
||||
Section {
|
||||
ForEach(localSources) { source in
|
||||
sourceSectionRows(for: source)
|
||||
}
|
||||
} header: {
|
||||
SidebarSourcesSectionHeaderView(title: "Libraries")
|
||||
}
|
||||
}
|
||||
|
||||
if !connectedDevices.isEmpty {
|
||||
Section {
|
||||
ForEach(connectedDevices) { entry in
|
||||
connectedDeviceSectionRows(for: entry)
|
||||
}
|
||||
} header: {
|
||||
SidebarSourcesSectionHeaderView(title: "Connected Devices")
|
||||
}
|
||||
} header: {
|
||||
SidebarSourcesSectionHeaderView()
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
@ -88,6 +85,45 @@ struct SourcesSidebarView: View {
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
||||
SourceHeaderRow(title: source.displayName)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
.contextMenu {
|
||||
Button("Rescan \"\(source.displayName)\"") {
|
||||
rescanSourceAction(source)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||
removeSourceAction(source)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(filters(source)) { filter in
|
||||
SidebarFilterRow(filter: filter, isIndented: true)
|
||||
.tag(filter.selection as SidebarSelection?)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
||||
if let source = matchedSource(entry) {
|
||||
sourceSectionRows(for: source)
|
||||
} else {
|
||||
ConnectedDeviceRow(
|
||||
entry: entry,
|
||||
addAction: entry.hasMinecraftContainer ? {
|
||||
addConnectedDeviceAction(entry)
|
||||
} : nil
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarFilterRow: View {
|
||||
@ -112,8 +148,10 @@ private struct SidebarFilterRow: View {
|
||||
}
|
||||
|
||||
private struct SidebarSourcesSectionHeaderView: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text("Libraries")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(nil)
|
||||
@ -130,6 +168,84 @@ private struct SourceHeaderRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectedDeviceRow: View {
|
||||
let entry: ConnectedDeviceSidebarEntry
|
||||
let addAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.frame(width: 16)
|
||||
.foregroundStyle(iconColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.device.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(titleColor)
|
||||
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if let addAction {
|
||||
Button("Add") {
|
||||
addAction()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.opacity(addAction == nil ? 0.68 : 1)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if entry.hasMinecraftContainer {
|
||||
return "iphone.gen3"
|
||||
}
|
||||
|
||||
switch entry.device.trustState {
|
||||
case .trusted:
|
||||
return "iphone.slash"
|
||||
case .locked, .untrusted:
|
||||
return "lock.iphone"
|
||||
case .unavailable:
|
||||
return "iphone.gen3.slash"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
entry.hasMinecraftContainer ? .appAccent : .secondary
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
addAction == nil ? .secondary : .primary
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let errorDescription = entry.discoveryErrorDescription, !errorDescription.isEmpty {
|
||||
return errorDescription
|
||||
}
|
||||
|
||||
switch entry.device.trustState {
|
||||
case .trusted:
|
||||
if entry.hasMinecraftContainer, let container = entry.minecraftContainer {
|
||||
return "Minecraft found in \(container.appName)"
|
||||
}
|
||||
|
||||
return "No Minecraft source found"
|
||||
case .locked:
|
||||
return "Unlock this device to inspect apps"
|
||||
case .untrusted:
|
||||
return "Trust this device to inspect apps"
|
||||
case .unavailable:
|
||||
return "Device unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarFooterView: View {
|
||||
let state: SidebarFooterState
|
||||
let revealAction: (URL) -> Void
|
||||
|
||||
@ -22,6 +22,22 @@ struct AppleMobileDeviceApplicationSummary: Sendable {
|
||||
let supportsOpeningDocumentsInPlace: Bool
|
||||
}
|
||||
|
||||
struct AppleMobileMinecraftLibraryItemSummary: Sendable {
|
||||
let contentType: String
|
||||
let collectionFolderName: String
|
||||
let relativePath: String
|
||||
let folderName: String
|
||||
let displayName: String
|
||||
let packUUID: String?
|
||||
let packVersion: String?
|
||||
let minimumEngineVersion: String?
|
||||
}
|
||||
|
||||
struct AppleMobileDevicePathMetrics: Sendable {
|
||||
let sizeBytes: Int64?
|
||||
let modifiedDate: Date?
|
||||
}
|
||||
|
||||
enum AppleMobileDeviceAccess {
|
||||
static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
@ -113,10 +129,158 @@ enum AppleMobileDeviceAccess {
|
||||
return AppleMobileDeviceApplicationSummary(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
displayName: displayName,
|
||||
fileSharingEnabled: application["uiFileSharingEnabled"] as? Bool ?? false,
|
||||
supportsOpeningDocumentsInPlace: application["supportsOpeningDocumentsInPlace"] as? Bool ?? false
|
||||
fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
|
||||
supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
static func listDirectory(
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> [String] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 7,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
|
||||
}.value
|
||||
}
|
||||
|
||||
static func fileData(
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> Data {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let data = WMMCopyFirstConnectedDeviceAppFileData(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 8,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return data as Data
|
||||
}.value
|
||||
}
|
||||
|
||||
static func minecraftLibrarySnapshot(
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
|
||||
)
|
||||
}
|
||||
|
||||
guard let rawItems = response["items"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 6,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawItems.compactMap { item in
|
||||
guard
|
||||
let contentType = item["contentType"] as? String,
|
||||
let collectionFolderName = item["collectionFolderName"] as? String,
|
||||
let relativePath = item["relativePath"] as? String,
|
||||
let folderName = item["folderName"] as? String,
|
||||
let displayName = item["displayName"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleMobileMinecraftLibraryItemSummary(
|
||||
contentType: contentType,
|
||||
collectionFolderName: collectionFolderName,
|
||||
relativePath: relativePath,
|
||||
folderName: folderName,
|
||||
displayName: displayName,
|
||||
packUUID: (item["packUUID"] as? String)?.lowercased(),
|
||||
packVersion: item["packVersion"] as? String,
|
||||
minimumEngineVersion: item["minimumEngineVersion"] as? String
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
static func pathMetrics(
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> AppleMobileDevicePathMetrics {
|
||||
try await Task.detached(priority: .utility) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 9,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
|
||||
)
|
||||
}
|
||||
|
||||
let rawSize = response["sizeBytes"]
|
||||
let sizeBytes: Int64?
|
||||
switch rawSize {
|
||||
case let number as NSNumber:
|
||||
sizeBytes = number.int64Value
|
||||
case let value as Int64:
|
||||
sizeBytes = value
|
||||
case let value as Int:
|
||||
sizeBytes = Int64(value)
|
||||
default:
|
||||
sizeBytes = nil
|
||||
}
|
||||
|
||||
return AppleMobileDevicePathMetrics(
|
||||
sizeBytes: sizeBytes,
|
||||
modifiedDate: response["modifiedDate"] as? Date
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func flexibleBool(from value: Any?) -> Bool {
|
||||
switch value {
|
||||
case let value as Bool:
|
||||
return value
|
||||
case let value as NSNumber:
|
||||
return value.boolValue
|
||||
case let value as NSString:
|
||||
return value.boolValue
|
||||
case let value as String:
|
||||
return NSString(string: value).boolValue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,27 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSData * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppFileData(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT BOOL
|
||||
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
NSString *bundleIdentifier,
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
#import <CoreFoundation/CoreFoundation.h>
|
||||
#import <dlfcn.h>
|
||||
#import <limits.h>
|
||||
|
||||
NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain";
|
||||
|
||||
@ -103,6 +104,9 @@ typedef int (*AFCConnectionCloseFn)(AFCConnectionRef connection);
|
||||
typedef int (*AFCDirectoryOpenFn)(AFCConnectionRef connection, const char *path, AFCDirectoryRef *directory);
|
||||
typedef int (*AFCDirectoryReadFn)(AFCConnectionRef connection, AFCDirectoryRef directory, char **directoryEntry);
|
||||
typedef int (*AFCDirectoryCloseFn)(AFCConnectionRef connection, AFCDirectoryRef directory);
|
||||
typedef int (*AFCFileInfoOpenFn)(AFCConnectionRef connection, const char *path, AFCIteratorRef *iterator);
|
||||
typedef int (*AFCKeyValueReadFn)(AFCIteratorRef iterator, char **key, char **value);
|
||||
typedef int (*AFCKeyValueCloseFn)(AFCIteratorRef iterator);
|
||||
typedef int (*AFCFileRefOpenFn)(AFCConnectionRef connection, const char *path, uint64_t mode, AFCFileDescriptorRef *fileDescriptor);
|
||||
typedef int (*AFCFileRefReadFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor, void *buffer, size_t *length);
|
||||
typedef int (*AFCFileRefCloseFn)(AFCConnectionRef connection, AFCFileDescriptorRef fileDescriptor);
|
||||
@ -137,6 +141,9 @@ typedef struct {
|
||||
AFCDirectoryOpenFn AFCDirectoryOpen;
|
||||
AFCDirectoryReadFn AFCDirectoryRead;
|
||||
AFCDirectoryCloseFn AFCDirectoryClose;
|
||||
AFCFileInfoOpenFn AFCFileInfoOpen;
|
||||
AFCKeyValueReadFn AFCKeyValueRead;
|
||||
AFCKeyValueCloseFn AFCKeyValueClose;
|
||||
AFCFileRefOpenFn AFCFileRefOpen;
|
||||
AFCFileRefReadFn AFCFileRefRead;
|
||||
AFCFileRefCloseFn AFCFileRefClose;
|
||||
@ -209,6 +216,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
||||
functions->AFCDirectoryOpen = (AFCDirectoryOpenFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryOpen");
|
||||
functions->AFCDirectoryRead = (AFCDirectoryReadFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryRead");
|
||||
functions->AFCDirectoryClose = (AFCDirectoryCloseFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryClose");
|
||||
functions->AFCFileInfoOpen = (AFCFileInfoOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileInfoOpen");
|
||||
functions->AFCKeyValueRead = (AFCKeyValueReadFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueRead");
|
||||
functions->AFCKeyValueClose = (AFCKeyValueCloseFn)WMMLoadSymbol(frameworkHandle, "AFCKeyValueClose");
|
||||
functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen");
|
||||
functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead");
|
||||
functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose");
|
||||
@ -241,6 +251,9 @@ static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **erro
|
||||
functions->AFCDirectoryOpen == NULL ||
|
||||
functions->AFCDirectoryRead == NULL ||
|
||||
functions->AFCDirectoryClose == NULL ||
|
||||
functions->AFCFileInfoOpen == NULL ||
|
||||
functions->AFCKeyValueRead == NULL ||
|
||||
functions->AFCKeyValueClose == NULL ||
|
||||
functions->AFCFileRefOpen == NULL ||
|
||||
functions->AFCFileRefRead == NULL ||
|
||||
functions->AFCFileRefClose == NULL) {
|
||||
@ -291,7 +304,7 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 2.0, false);
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false);
|
||||
functions->AMDeviceNotificationUnsubscribe(subscription);
|
||||
|
||||
if (context.device == NULL && error != NULL) {
|
||||
@ -570,6 +583,166 @@ static int WMMReadAFCDirectory(
|
||||
return result;
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, NSString *> * _Nullable WMMCopyAFCFileInfo(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *path,
|
||||
NSError **error
|
||||
) {
|
||||
AFCIteratorRef iterator = NULL;
|
||||
const int openStatus = functions->AFCFileInfoOpen(
|
||||
afcConnection,
|
||||
path.fileSystemRepresentation,
|
||||
&iterator
|
||||
);
|
||||
if (openStatus != 0 || iterator == NULL) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(openStatus, [NSString stringWithFormat:@"AFCFileInfoOpen failed for %@ (%d).", path, openStatus]);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableDictionary<NSString *, NSString *> *info = [NSMutableDictionary dictionary];
|
||||
while (true) {
|
||||
char *key = NULL;
|
||||
char *value = NULL;
|
||||
const int readStatus = functions->AFCKeyValueRead(iterator, &key, &value);
|
||||
if (readStatus != 0) {
|
||||
functions->AFCKeyValueClose(iterator);
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(readStatus, [NSString stringWithFormat:@"AFCKeyValueRead failed for %@ (%d).", path, readStatus]);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (key == NULL || value == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
NSString *keyString = [NSString stringWithUTF8String:key];
|
||||
NSString *valueString = [NSString stringWithUTF8String:value];
|
||||
if (keyString.length > 0 && valueString.length > 0) {
|
||||
info[keyString] = valueString;
|
||||
}
|
||||
}
|
||||
|
||||
functions->AFCKeyValueClose(iterator);
|
||||
return info;
|
||||
}
|
||||
|
||||
static unsigned long long WMMParseUnsignedLongLong(NSString *value) {
|
||||
if (value.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
NSScanner *hexScanner = [NSScanner scannerWithString:value];
|
||||
unsigned long long hexValue = 0;
|
||||
if (([value hasPrefix:@"0x"] || [value hasPrefix:@"0X"])
|
||||
&& [hexScanner scanString:@"0x" intoString:nil]
|
||||
&& [hexScanner scanHexLongLong:&hexValue]) {
|
||||
return hexValue;
|
||||
}
|
||||
|
||||
return strtoull(value.UTF8String, NULL, 10);
|
||||
}
|
||||
|
||||
static NSDate * _Nullable WMMDateFromAFCTimestampString(NSString *value) {
|
||||
unsigned long long rawValue = WMMParseUnsignedLongLong(value);
|
||||
if (rawValue == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSTimeInterval seconds;
|
||||
if (rawValue > 10000000000000000ULL) {
|
||||
seconds = (NSTimeInterval)rawValue / 1000000000.0;
|
||||
} else if (rawValue > 10000000000000ULL) {
|
||||
seconds = (NSTimeInterval)rawValue / 1000000.0;
|
||||
} else if (rawValue > 10000000000ULL) {
|
||||
seconds = (NSTimeInterval)rawValue / 1000.0;
|
||||
} else {
|
||||
seconds = (NSTimeInterval)rawValue;
|
||||
}
|
||||
|
||||
return [NSDate dateWithTimeIntervalSince1970:seconds];
|
||||
}
|
||||
|
||||
static NSDate * _Nullable WMMModificationDateFromAFCInfo(NSDictionary<NSString *, NSString *> *info) {
|
||||
NSString *candidate = info[@"st_mtime"] ?: info[@"st_birthtime"];
|
||||
if (candidate.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return WMMDateFromAFCTimestampString(candidate);
|
||||
}
|
||||
|
||||
static long long WMMFileSizeFromAFCInfo(NSDictionary<NSString *, NSString *> *info) {
|
||||
NSString *candidate = info[@"st_size"];
|
||||
if (candidate.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
unsigned long long parsed = WMMParseUnsignedLongLong(candidate);
|
||||
if (parsed > LLONG_MAX) {
|
||||
return LLONG_MAX;
|
||||
}
|
||||
|
||||
return (long long)parsed;
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, id> * _Nullable WMMCopyAFCTreeMetrics(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath,
|
||||
NSError **error
|
||||
) {
|
||||
NSDictionary<NSString *, NSString *> *info = WMMCopyAFCFileInfo(functions, afcConnection, remotePath, error);
|
||||
if (info == nil) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDate *latestModificationDate = WMMModificationDateFromAFCInfo(info);
|
||||
NSMutableArray<NSString *> *entries = nil;
|
||||
const int directoryStatus = WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries);
|
||||
if (directoryStatus != 0) {
|
||||
return @{
|
||||
@"sizeBytes": @(WMMFileSizeFromAFCInfo(info)),
|
||||
@"modifiedDate": latestModificationDate ?: [NSNull null]
|
||||
};
|
||||
}
|
||||
|
||||
long long totalSize = 0;
|
||||
for (NSString *entry in entries) {
|
||||
if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *childRemotePath = [remotePath hasSuffix:@"/"]
|
||||
? [remotePath stringByAppendingString:entry]
|
||||
: [remotePath stringByAppendingPathComponent:entry];
|
||||
NSDictionary<NSString *, id> *childMetrics = WMMCopyAFCTreeMetrics(
|
||||
functions,
|
||||
afcConnection,
|
||||
childRemotePath,
|
||||
error
|
||||
);
|
||||
if (childMetrics == nil) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
totalSize += [childMetrics[@"sizeBytes"] longLongValue];
|
||||
NSDate *childModifiedDate = childMetrics[@"modifiedDate"];
|
||||
if ([childModifiedDate isKindOfClass:[NSDate class]]
|
||||
&& (latestModificationDate == nil || [childModifiedDate compare:latestModificationDate] == NSOrderedDescending)) {
|
||||
latestModificationDate = childModifiedDate;
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
@"sizeBytes": @(totalSize),
|
||||
@"modifiedDate": latestModificationDate ?: [NSNull null]
|
||||
};
|
||||
}
|
||||
|
||||
static BOOL WMMCopyAFCFileToLocalURL(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
@ -596,6 +769,14 @@ static BOOL WMMCopyAFCFileToLocalURL(
|
||||
|
||||
NSFileHandle *handle = [NSFileHandle fileHandleForWritingToURL:localFileURL error:error];
|
||||
if (handle == nil) {
|
||||
if (error != NULL && *error != nil) {
|
||||
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to open local file %@ for remote AFC path %@: %@",
|
||||
localFileURL.path,
|
||||
remotePath,
|
||||
(*error).localizedDescription]
|
||||
}];
|
||||
}
|
||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||
return NO;
|
||||
}
|
||||
@ -624,6 +805,14 @@ static BOOL WMMCopyAFCFileToLocalURL(
|
||||
|
||||
NSData *chunk = [NSData dataWithBytes:buffer.bytes length:bytesToRead];
|
||||
if (![handle writeData:chunk error:error]) {
|
||||
if (error != NULL && *error != nil) {
|
||||
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed writing local file %@ for remote AFC path %@: %@",
|
||||
localFileURL.path,
|
||||
remotePath,
|
||||
(*error).localizedDescription]
|
||||
}];
|
||||
}
|
||||
success = NO;
|
||||
break;
|
||||
}
|
||||
@ -646,6 +835,14 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
||||
if (directoryStatus == 0) {
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if (![fileManager createDirectoryAtURL:localURL withIntermediateDirectories:YES attributes:nil error:error]) {
|
||||
if (error != NULL && *error != nil) {
|
||||
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to create local directory %@ for remote AFC path %@: %@",
|
||||
localURL.path,
|
||||
remotePath,
|
||||
(*error).localizedDescription]
|
||||
}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@ -660,8 +857,16 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
||||
} else {
|
||||
childRemotePath = [childRemotePath stringByAppendingPathComponent:entry];
|
||||
}
|
||||
NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry isDirectory:YES];
|
||||
NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry];
|
||||
if (!WMMCopyAFCTreeToLocalURL(functions, afcConnection, childRemotePath, childLocalURL, error)) {
|
||||
if (error != NULL && *error != nil) {
|
||||
*error = [NSError errorWithDomain:(*error).domain code:(*error).code userInfo:@{
|
||||
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed copying remote AFC path %@ into %@: %@",
|
||||
childRemotePath,
|
||||
childLocalURL.path,
|
||||
(*error).localizedDescription]
|
||||
}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
@ -672,6 +877,296 @@ static BOOL WMMCopyAFCTreeToLocalURL(
|
||||
return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error);
|
||||
}
|
||||
|
||||
static NSString *WMMNormalizedAFCPath(NSString *path) {
|
||||
NSString *normalizedPath = path.length == 0 ? @"/" : path;
|
||||
if (![normalizedPath hasPrefix:@"/"]) {
|
||||
normalizedPath = [@"/" stringByAppendingString:normalizedPath];
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
static NSData * _Nullable WMMCopyAFCFileData(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath,
|
||||
NSError **error
|
||||
) {
|
||||
AFCFileDescriptorRef fileDescriptor = NULL;
|
||||
const int openStatus = functions->AFCFileRefOpen(
|
||||
afcConnection,
|
||||
remotePath.fileSystemRepresentation,
|
||||
1,
|
||||
&fileDescriptor
|
||||
);
|
||||
if (openStatus != 0 || fileDescriptor == NULL) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(openStatus, [NSString stringWithFormat:@"AFCFileRefOpen failed for %@ (%d).", remotePath, openStatus]);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableData *data = [NSMutableData data];
|
||||
NSMutableData *buffer = [NSMutableData dataWithLength:64 * 1024];
|
||||
while (true) {
|
||||
size_t bytesToRead = buffer.length;
|
||||
const int readStatus = functions->AFCFileRefRead(
|
||||
afcConnection,
|
||||
fileDescriptor,
|
||||
buffer.mutableBytes,
|
||||
&bytesToRead
|
||||
);
|
||||
if (readStatus != 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(readStatus, [NSString stringWithFormat:@"AFCFileRefRead failed for %@ (%d).", remotePath, readStatus]);
|
||||
}
|
||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (bytesToRead == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
[data appendBytes:buffer.bytes length:bytesToRead];
|
||||
}
|
||||
|
||||
functions->AFCFileRefClose(afcConnection, fileDescriptor);
|
||||
return data;
|
||||
}
|
||||
|
||||
static BOOL WMMEntryArrayContainsName(NSArray<NSString *> *entries, NSString *candidate) {
|
||||
for (NSString *entry in entries) {
|
||||
if ([entry isEqualToString:candidate]) {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
static NSString * _Nullable WMMReadUTF8TextFile(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath
|
||||
) {
|
||||
NSData *data = WMMCopyAFCFileData(functions, afcConnection, remotePath, NULL);
|
||||
if (data == nil) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
return [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, id> * _Nullable WMMReadManifestHeader(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *remotePath
|
||||
) {
|
||||
NSData *data = WMMCopyAFCFileData(functions, afcConnection, remotePath, NULL);
|
||||
if (data == nil) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDictionary *jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||
if (![jsonObject isKindOfClass:[NSDictionary class]]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDictionary *header = jsonObject[@"header"];
|
||||
if (![header isKindOfClass:[NSDictionary class]]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
static NSString * _Nullable WMMVersionStringFromValue(id value) {
|
||||
if ([value isKindOfClass:[NSString class]]) {
|
||||
NSString *stringValue = [(NSString *)value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
return stringValue.length > 0 ? stringValue : nil;
|
||||
}
|
||||
|
||||
if ([value isKindOfClass:[NSArray class]]) {
|
||||
NSMutableArray<NSString *> *components = [NSMutableArray array];
|
||||
for (id component in (NSArray *)value) {
|
||||
if ([component isKindOfClass:[NSNumber class]]) {
|
||||
[components addObject:[(NSNumber *)component stringValue]];
|
||||
} else if ([component isKindOfClass:[NSString class]]) {
|
||||
[components addObject:(NSString *)component];
|
||||
}
|
||||
}
|
||||
return components.count > 0 ? [components componentsJoinedByString:@"."] : nil;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL WMMIsCandidateItem(NSString *contentType, NSArray<NSString *> *entries) {
|
||||
if ([contentType isEqualToString:@"World"]) {
|
||||
return WMMEntryArrayContainsName(entries, @"level.dat")
|
||||
|| WMMEntryArrayContainsName(entries, @"db")
|
||||
|| WMMEntryArrayContainsName(entries, @"levelname.txt");
|
||||
}
|
||||
|
||||
return WMMEntryArrayContainsName(entries, @"manifest.json")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.png")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpeg")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg");
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *contentType,
|
||||
NSString *collectionFolderName,
|
||||
NSString *itemRemotePath,
|
||||
NSString *itemRelativePath,
|
||||
NSString *folderName,
|
||||
NSArray<NSString *> *entries
|
||||
) {
|
||||
NSMutableDictionary<NSString *, id> *summary = [@{
|
||||
@"contentType": contentType,
|
||||
@"collectionFolderName": collectionFolderName,
|
||||
@"relativePath": itemRelativePath,
|
||||
@"folderName": folderName
|
||||
} mutableCopy];
|
||||
|
||||
NSString *displayName = folderName;
|
||||
if ([contentType isEqualToString:@"World"]) {
|
||||
NSString *levelName = WMMReadUTF8TextFile(
|
||||
functions,
|
||||
afcConnection,
|
||||
[itemRemotePath stringByAppendingPathComponent:@"levelname.txt"]
|
||||
);
|
||||
if (levelName.length > 0) {
|
||||
displayName = levelName;
|
||||
}
|
||||
} else {
|
||||
NSDictionary<NSString *, id> *header = WMMReadManifestHeader(
|
||||
functions,
|
||||
afcConnection,
|
||||
[itemRemotePath stringByAppendingPathComponent:@"manifest.json"]
|
||||
);
|
||||
NSString *manifestName = [header[@"name"] isKindOfClass:[NSString class]] ? header[@"name"] : nil;
|
||||
if (manifestName.length > 0) {
|
||||
displayName = manifestName;
|
||||
}
|
||||
|
||||
if ([header[@"uuid"] isKindOfClass:[NSString class]]) {
|
||||
summary[@"packUUID"] = [header[@"uuid"] lowercaseString];
|
||||
}
|
||||
NSString *version = WMMVersionStringFromValue(header[@"version"]);
|
||||
if (version.length > 0) {
|
||||
summary[@"packVersion"] = version;
|
||||
}
|
||||
NSString *minimumEngineVersion = WMMVersionStringFromValue(header[@"min_engine_version"]);
|
||||
if (minimumEngineVersion.length > 0) {
|
||||
summary[@"minimumEngineVersion"] = minimumEngineVersion;
|
||||
}
|
||||
}
|
||||
|
||||
summary[@"displayName"] = displayName;
|
||||
summary[@"hasIcon"] = @(
|
||||
WMMEntryArrayContainsName(entries, @"world_icon.png")
|
||||
|| WMMEntryArrayContainsName(entries, @"world_icon.jpeg")
|
||||
|| WMMEntryArrayContainsName(entries, @"world_icon.jpg")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.png")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpeg")
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg")
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
static void WMMAppendCollectionSummaries(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
NSString *rootRemotePath,
|
||||
NSString *collectionFolderName,
|
||||
NSString *contentType,
|
||||
NSMutableArray<NSDictionary<NSString *, id> *> *results
|
||||
) {
|
||||
NSString *collectionRemotePath = [rootRemotePath stringByAppendingPathComponent:collectionFolderName];
|
||||
NSMutableArray<NSString *> *itemFolderNames = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, collectionRemotePath, &itemFolderNames) != 0 || itemFolderNames == nil) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (NSString *itemFolderName in itemFolderNames) {
|
||||
if ([itemFolderName isEqualToString:@"."] || [itemFolderName isEqualToString:@".."]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *itemRemotePath = [collectionRemotePath stringByAppendingPathComponent:itemFolderName];
|
||||
NSMutableArray<NSString *> *itemEntries = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, itemRemotePath, &itemEntries) != 0 || itemEntries == nil) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!WMMIsCandidateItem(contentType, itemEntries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName];
|
||||
[results addObject:WMMBuildMinecraftItemSummary(
|
||||
functions,
|
||||
afcConnection,
|
||||
contentType,
|
||||
collectionFolderName,
|
||||
itemRemotePath,
|
||||
itemRelativePath,
|
||||
itemFolderName,
|
||||
itemEntries
|
||||
)];
|
||||
|
||||
if (![contentType isEqualToString:@"World"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSArray<NSDictionary<NSString *, NSString *> *> *embeddedCollections = @[
|
||||
@{ @"folder": @"behavior_packs", @"type": @"Behavior Pack" },
|
||||
@{ @"folder": @"resource_packs", @"type": @"Resource Pack" }
|
||||
];
|
||||
|
||||
for (NSDictionary<NSString *, NSString *> *embeddedCollection in embeddedCollections) {
|
||||
NSString *embeddedFolder = embeddedCollection[@"folder"];
|
||||
NSString *embeddedType = embeddedCollection[@"type"];
|
||||
NSString *embeddedCollectionPath = [itemRemotePath stringByAppendingPathComponent:embeddedFolder];
|
||||
NSMutableArray<NSString *> *embeddedFolderNames = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, embeddedCollectionPath, &embeddedFolderNames) != 0 || embeddedFolderNames == nil) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (NSString *embeddedFolderName in embeddedFolderNames) {
|
||||
if ([embeddedFolderName isEqualToString:@"."] || [embeddedFolderName isEqualToString:@".."]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *embeddedItemPath = [embeddedCollectionPath stringByAppendingPathComponent:embeddedFolderName];
|
||||
NSMutableArray<NSString *> *embeddedEntries = nil;
|
||||
if (WMMReadAFCDirectory(functions, afcConnection, embeddedItemPath, &embeddedEntries) != 0 || embeddedEntries == nil) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!WMMIsCandidateItem(embeddedType, embeddedEntries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]];
|
||||
[results addObject:WMMBuildMinecraftItemSummary(
|
||||
functions,
|
||||
afcConnection,
|
||||
embeddedType,
|
||||
embeddedFolder,
|
||||
embeddedItemPath,
|
||||
embeddedRelativePath,
|
||||
embeddedFolderName,
|
||||
embeddedEntries
|
||||
)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceSummary(NSError **error) {
|
||||
WMMMobileDeviceFunctions functions;
|
||||
@ -996,6 +1491,207 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
||||
};
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
) {
|
||||
if (bundleIdentifier.length == 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(16, @"A bundle identifier is required.");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
&functions,
|
||||
device,
|
||||
bundleIdentifier,
|
||||
&backingServiceConnection,
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *normalizedRootPath = WMMNormalizedAFCPath(relativePath);
|
||||
NSMutableArray<NSDictionary<NSString *, id> *> *items = [NSMutableArray array];
|
||||
NSArray<NSDictionary<NSString *, NSString *> *> *collections = @[
|
||||
@{ @"folder": @"minecraftWorlds", @"type": @"World" },
|
||||
@{ @"folder": @"behavior_packs", @"type": @"Behavior Pack" },
|
||||
@{ @"folder": @"resource_packs", @"type": @"Resource Pack" },
|
||||
@{ @"folder": @"skin_packs", @"type": @"Skin Pack" },
|
||||
@{ @"folder": @"world_templates", @"type": @"World Template" }
|
||||
];
|
||||
|
||||
for (NSDictionary<NSString *, NSString *> *collection in collections) {
|
||||
WMMAppendCollectionSummaries(
|
||||
&functions,
|
||||
afcConnection,
|
||||
normalizedRootPath,
|
||||
collection[@"folder"],
|
||||
collection[@"type"],
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
return @{
|
||||
@"bundleIdentifier": bundleIdentifier,
|
||||
@"path": normalizedRootPath,
|
||||
@"items": items
|
||||
};
|
||||
}
|
||||
|
||||
NSData * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppFileData(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
) {
|
||||
if (bundleIdentifier.length == 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(17, @"A bundle identifier is required.");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
&functions,
|
||||
device,
|
||||
bundleIdentifier,
|
||||
&backingServiceConnection,
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
|
||||
NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
) {
|
||||
if (bundleIdentifier.length == 0) {
|
||||
if (error != NULL) {
|
||||
*error = WMMMakeError(18, @"A bundle identifier is required.");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
&functions,
|
||||
device,
|
||||
bundleIdentifier,
|
||||
&backingServiceConnection,
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
|
||||
NSDictionary<NSString *, id> *metrics = WMMCopyAFCTreeMetrics(
|
||||
&functions,
|
||||
afcConnection,
|
||||
normalizedPath,
|
||||
error
|
||||
);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
if (metrics == nil) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return @{
|
||||
@"bundleIdentifier": bundleIdentifier,
|
||||
@"path": normalizedPath,
|
||||
@"sizeBytes": metrics[@"sizeBytes"] ?: @0,
|
||||
@"modifiedDate": metrics[@"modifiedDate"] ?: [NSNull null]
|
||||
};
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
NSString *bundleIdentifier,
|
||||
|
||||
@ -8,13 +8,42 @@
|
||||
import Foundation
|
||||
|
||||
struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
private let mirrorRootURL: URL
|
||||
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "connected-device.apple-mobile-device"
|
||||
|
||||
nonisolated init(
|
||||
mirrorRootURL: URL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("WorldManagerConnectedDevices", isDirectory: true)
|
||||
) {
|
||||
self.mirrorRootURL = mirrorRootURL
|
||||
nonisolated init() {}
|
||||
|
||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||
_ = source
|
||||
return SourceAccessDescriptor(
|
||||
accessorIdentifier: accessorIdentifier,
|
||||
kind: .connectedDevice,
|
||||
capabilities: .connectedDevice,
|
||||
refreshStrategy: .staged
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||
return .unavailable
|
||||
}
|
||||
|
||||
do {
|
||||
let devices = try await listConnectedDevices()
|
||||
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
|
||||
return .disconnected
|
||||
}
|
||||
|
||||
switch device.trustState {
|
||||
case .trusted:
|
||||
return .available
|
||||
case .locked, .untrusted:
|
||||
return .limited
|
||||
case .unavailable:
|
||||
return .disconnected
|
||||
}
|
||||
} catch {
|
||||
return .disconnected
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
|
||||
@ -35,7 +64,11 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
let applications = try await AppleMobileDeviceAccess.listApplications()
|
||||
|
||||
return applications
|
||||
.filter { $0.fileSharingEnabled }
|
||||
.filter { application in
|
||||
application.fileSharingEnabled
|
||||
|| application.supportsOpeningDocumentsInPlace
|
||||
|| application.bundleIdentifier == "com.mojang.minecraftpe"
|
||||
}
|
||||
.map { application in
|
||||
DeviceAppContainer(
|
||||
deviceUDID: device.udid,
|
||||
@ -58,7 +91,10 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
||||
nonisolated func discoverItems(
|
||||
for source: MinecraftSource,
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem] {
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
throw SourceAccessError.accessFailed(
|
||||
reason: "The selected source is not backed by a connected mobile device."
|
||||
@ -72,38 +108,421 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
)
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let mirrorURL = mirrorRootURL
|
||||
.appendingPathComponent(container.deviceUDID, isDirectory: true)
|
||||
.appendingPathComponent(container.appID.replacingOccurrences(of: ".", with: "_"), isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: requestedSubpath
|
||||
)
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(at: mirrorURL, withIntermediateDirectories: true)
|
||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: requestedSubpath,
|
||||
destinationDirectoryURL: mirrorURL
|
||||
)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: mirrorURL)
|
||||
throw SourceAccessError.accessFailed(reason: error.localizedDescription)
|
||||
let items = summaries.compactMap { summary in
|
||||
makeItem(from: summary, source: source)
|
||||
}
|
||||
|
||||
return PreparedScanRoot(
|
||||
sourceID: source.id,
|
||||
rootURL: mirrorURL,
|
||||
mountPointURL: mirrorURL,
|
||||
cleanupBehavior: .deleteTemporaryDirectory
|
||||
)
|
||||
for item in items {
|
||||
onDiscovered(item)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
||||
guard case .deleteTemporaryDirectory = preparedScanRoot.cleanupBehavior,
|
||||
let mountPointURL = preparedScanRoot.mountPointURL else {
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||
var enrichedItem = item
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
enrichedItem.metadataLoaded = true
|
||||
return enrichedItem
|
||||
}
|
||||
|
||||
enrichedItem.iconURL = await loadRemoteIcon(for: item, source: source, container: container)
|
||||
enrichedItem.modifiedDate = nil
|
||||
|
||||
if item.contentType == .world {
|
||||
if let levelDatPath = remoteItemPath(for: item, in: source, appending: "level.dat"),
|
||||
let levelDatData = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: levelDatPath
|
||||
) {
|
||||
enrichedItem.worldMetadata = BedrockLevelMetadataDecoder.decode(fromLevelDatData: levelDatData)
|
||||
enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
|
||||
}
|
||||
|
||||
enrichedItem.packReferences = await loadWorldPackReferences(for: item, source: source, container: container)
|
||||
} else {
|
||||
enrichedItem.lastPlayedDate = nil
|
||||
enrichedItem.packReferences = []
|
||||
}
|
||||
|
||||
enrichedItem.metadataLoaded = true
|
||||
enrichedItem.sizeLoaded = false
|
||||
return enrichedItem
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
var sizedItem = item
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
sizedItem.sizeLoaded = true
|
||||
return sizedItem
|
||||
}
|
||||
|
||||
if let remoteItemPath = remoteItemPath(for: item, in: source),
|
||||
let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath
|
||||
) {
|
||||
sizedItem.sizeBytes = metrics.sizeBytes
|
||||
if sizedItem.modifiedDate == nil {
|
||||
sizedItem.modifiedDate = metrics.modifiedDate
|
||||
}
|
||||
}
|
||||
|
||||
sizedItem.sizeLoaded = true
|
||||
return sizedItem
|
||||
}
|
||||
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
return []
|
||||
}
|
||||
|
||||
guard let remoteFolderPath = remoteItemPath(for: item, in: source) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let entries = try await AppleMobileDeviceAccess.listDirectory(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteFolderPath
|
||||
)
|
||||
|
||||
return entries
|
||||
.map { entry in
|
||||
let isDirectory = !NSString(string: entry).pathExtension.isEmpty ? false : true
|
||||
return DirectoryPreviewEntry(name: entry, isDirectory: isDirectory)
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.isDirectory != rhs.isDirectory {
|
||||
return lhs.isDirectory && !rhs.isDirectory
|
||||
}
|
||||
|
||||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
return item.folderURL
|
||||
}
|
||||
|
||||
guard let remoteItemPath = remoteItemPath(for: item, in: source) else {
|
||||
throw SourceAccessError.accessFailed(reason: "Could not resolve the device path for this item.")
|
||||
}
|
||||
|
||||
let destinationURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("WMMConnectedDeviceReveal", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
|
||||
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
do {
|
||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath,
|
||||
destinationDirectoryURL: destinationURL
|
||||
)
|
||||
return destinationURL
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: destinationURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
guard source.origin.kind == .connectedDevice else {
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: mountPointURL)
|
||||
try? ConnectedDeviceMirrorCache.purgeRootURL(for: source.id)
|
||||
}
|
||||
|
||||
nonisolated private func makeItem(
|
||||
from summary: AppleMobileMinecraftLibraryItemSummary,
|
||||
source: MinecraftSource
|
||||
) -> MinecraftContentItem? {
|
||||
let contentType: MinecraftContentType
|
||||
switch summary.contentType {
|
||||
case MinecraftContentType.world.rawValue:
|
||||
contentType = .world
|
||||
case MinecraftContentType.behaviorPack.rawValue:
|
||||
contentType = .behaviorPack
|
||||
case MinecraftContentType.resourcePack.rawValue:
|
||||
contentType = .resourcePack
|
||||
case MinecraftContentType.skinPack.rawValue:
|
||||
contentType = .skinPack
|
||||
case MinecraftContentType.worldTemplate.rawValue:
|
||||
contentType = .worldTemplate
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
|
||||
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, isDirectory: true)
|
||||
return MinecraftContentItem(
|
||||
folderURL: folderURL,
|
||||
folderName: summary.folderName,
|
||||
contentType: contentType,
|
||||
collectionRootURL: collectionRootURL,
|
||||
displayName: summary.displayName,
|
||||
iconURL: nil,
|
||||
packUUID: summary.packUUID,
|
||||
packVersion: summary.packVersion,
|
||||
packMetadataDetails: PackMetadataDetails(minimumEngineVersion: summary.minimumEngineVersion),
|
||||
metadataLoaded: false,
|
||||
sizeLoaded: false
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func remoteItemPath(
|
||||
for item: MinecraftContentItem,
|
||||
in source: MinecraftSource,
|
||||
appending childPath: String? = nil
|
||||
) -> String? {
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rootPath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !rootPath.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "")
|
||||
guard !relativeItemPath.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let basePath = appendPathComponents(
|
||||
rootPath,
|
||||
components: relativeItemPath.split(separator: "/").map(String.init)
|
||||
)
|
||||
|
||||
if let childPath, !childPath.isEmpty {
|
||||
return NSString(string: basePath).appendingPathComponent(childPath)
|
||||
}
|
||||
|
||||
return basePath
|
||||
}
|
||||
|
||||
nonisolated private func loadRemoteIcon(
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource,
|
||||
container: DeviceAppContainer
|
||||
) async -> URL? {
|
||||
let candidateNames: [String]
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||
}
|
||||
|
||||
for candidateName in candidateNames {
|
||||
guard let remotePath = remoteItemPath(for: item, in: source, appending: candidateName) else {
|
||||
continue
|
||||
}
|
||||
guard let data = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remotePath
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let pathExtension = NSString(string: candidateName).pathExtension
|
||||
return await ImageCacheStore.shared.cachedImageURL(
|
||||
forRemoteData: data,
|
||||
cacheKey: "\(container.deviceUDID)::\(container.appID)::\(remotePath)",
|
||||
pathExtension: pathExtension
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private func appendPathComponents(_ root: String, components: [String]) -> String {
|
||||
components.reduce(root) { partial, component in
|
||||
NSString(string: partial).appendingPathComponent(component)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func loadWorldPackReferences(
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource,
|
||||
container: DeviceAppContainer
|
||||
) async -> [ContentPackReference] {
|
||||
var references: [ContentPackReference] = []
|
||||
|
||||
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
|
||||
let behaviorData = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: behaviorRefPath
|
||||
) {
|
||||
references.append(contentsOf: parsePackReferences(from: behaviorData, type: .behaviorPack))
|
||||
}
|
||||
|
||||
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
|
||||
let resourceData = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: resourceRefPath
|
||||
) {
|
||||
references.append(contentsOf: parsePackReferences(from: resourceData, type: .resourcePack))
|
||||
}
|
||||
|
||||
references.append(contentsOf: await loadEmbeddedPackReferences(
|
||||
for: item,
|
||||
source: source,
|
||||
container: container,
|
||||
folderName: "behavior_packs",
|
||||
type: .behaviorPack
|
||||
))
|
||||
references.append(contentsOf: await loadEmbeddedPackReferences(
|
||||
for: item,
|
||||
source: source,
|
||||
container: container,
|
||||
folderName: "resource_packs",
|
||||
type: .resourcePack
|
||||
))
|
||||
|
||||
return uniquePackReferences(references)
|
||||
}
|
||||
|
||||
nonisolated private func loadEmbeddedPackReferences(
|
||||
for item: MinecraftContentItem,
|
||||
source: MinecraftSource,
|
||||
container: DeviceAppContainer,
|
||||
folderName: String,
|
||||
type: MinecraftContentType
|
||||
) async -> [ContentPackReference] {
|
||||
guard let remoteFolderPath = remoteItemPath(for: item, in: source, appending: folderName) else {
|
||||
return []
|
||||
}
|
||||
|
||||
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteFolderPath
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var references: [ContentPackReference] = []
|
||||
for childFolder in childFolders {
|
||||
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
|
||||
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
|
||||
guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: manifestPath
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let metadata = parseManifestMetadata(from: manifestData, fallbackName: childFolder) else {
|
||||
continue
|
||||
}
|
||||
|
||||
references.append(
|
||||
ContentPackReference(
|
||||
name: metadata.name,
|
||||
type: type,
|
||||
iconURL: nil,
|
||||
uuid: metadata.uuid,
|
||||
version: metadata.version,
|
||||
source: .embeddedInWorld
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
nonisolated private func parsePackReferences(
|
||||
from data: Data,
|
||||
type: MinecraftContentType
|
||||
) -> [ContentPackReference] {
|
||||
guard let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return jsonObject.map { entry in
|
||||
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||
let version = versionString(from: entry["version"])
|
||||
return ContentPackReference(
|
||||
name: uuid ?? "Referenced Pack",
|
||||
type: type,
|
||||
iconURL: nil,
|
||||
uuid: uuid,
|
||||
version: version,
|
||||
source: .referencedByWorld
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func parseManifestMetadata(
|
||||
from data: Data,
|
||||
fallbackName: String
|
||||
) -> (name: String, uuid: String?, version: String?, minimumEngineVersion: String?)? {
|
||||
guard
|
||||
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let header = jsonObject["header"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? fallbackName
|
||||
|
||||
return (
|
||||
name: name,
|
||||
uuid: (header["uuid"] as? String)?.lowercased(),
|
||||
version: versionString(from: header["version"]),
|
||||
minimumEngineVersion: versionString(from: header["min_engine_version"])
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func versionString(from value: Any?) -> String? {
|
||||
if let versionString = value as? String, !versionString.isEmpty {
|
||||
return versionString
|
||||
}
|
||||
|
||||
if let versionArray = value as? [Any] {
|
||||
let components = versionArray.compactMap { component -> String? in
|
||||
if let intComponent = component as? Int {
|
||||
return String(intComponent)
|
||||
}
|
||||
if let stringComponent = component as? String {
|
||||
return stringComponent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ".")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||
var seen = Set<String>()
|
||||
var uniqueReferences: [ContentPackReference] = []
|
||||
|
||||
for reference in references {
|
||||
let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""]
|
||||
.joined(separator: "::")
|
||||
guard seen.insert(dedupeKey).inserted else {
|
||||
continue
|
||||
}
|
||||
uniqueReferences.append(reference)
|
||||
}
|
||||
|
||||
return uniqueReferences.sorted { lhs, rhs in
|
||||
if lhs.type != rhs.type {
|
||||
return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending
|
||||
}
|
||||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
//
|
||||
// ConnectedDeviceMirrorCache.swift
|
||||
// World Manager for Minecraft
|
||||
//
|
||||
// Created by OpenAI on 2026-05-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ConnectedDeviceMirrorCache {
|
||||
nonisolated static func rootURL(for sourceID: URL, fileManager: FileManager = .default) -> URL {
|
||||
let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
|
||||
|
||||
return applicationSupportURL
|
||||
.appendingPathComponent("World Manager for Minecraft", isDirectory: true)
|
||||
.appendingPathComponent("ConnectedDeviceCache", isDirectory: true)
|
||||
.appendingPathComponent(sanitizedComponent(for: sourceID), isDirectory: true)
|
||||
}
|
||||
|
||||
nonisolated static func purgeRootURL(for sourceID: URL, fileManager: FileManager = .default) throws {
|
||||
let rootURL = rootURL(for: sourceID, fileManager: fileManager)
|
||||
guard fileManager.fileExists(atPath: rootURL.path) else {
|
||||
return
|
||||
}
|
||||
|
||||
try fileManager.removeItem(at: rootURL)
|
||||
}
|
||||
|
||||
nonisolated private static func sanitizedComponent(for sourceID: URL) -> String {
|
||||
let rawValue = sourceID.absoluteString
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
|
||||
|
||||
let pieces = rawValue.unicodeScalars.map { scalar -> String in
|
||||
allowed.contains(scalar) ? String(scalar) : "_"
|
||||
}
|
||||
|
||||
return String(pieces.joined().prefix(180))
|
||||
}
|
||||
}
|
||||
@ -15,12 +15,18 @@ struct ConnectedDeviceSourceFactory: Sendable {
|
||||
container: DeviceAppContainer
|
||||
) -> MinecraftSource {
|
||||
let sourceID = makeSourceIdentifier(device: device, container: container)
|
||||
let placeholderFolderURL = URL(fileURLWithPath: "/Volumes/\(sourceID.lastPathComponent)", isDirectory: true)
|
||||
let cacheRootURL = ConnectedDeviceMirrorCache.rootURL(for: sourceID)
|
||||
|
||||
var source = MinecraftSource(
|
||||
sourceID: sourceID,
|
||||
folderURL: placeholderFolderURL,
|
||||
origin: .connectedDevice(device: device, container: container)
|
||||
folderURL: cacheRootURL,
|
||||
origin: .connectedDevice(device: device, container: container),
|
||||
accessDescriptor: SourceAccessDescriptor(
|
||||
accessorIdentifier: AppleMobileDeviceSourceAccess().accessorIdentifier,
|
||||
kind: .connectedDevice,
|
||||
capabilities: .connectedDevice,
|
||||
refreshStrategy: .staged
|
||||
)
|
||||
)
|
||||
source.displayName = displayName(for: device, container: container)
|
||||
return source
|
||||
|
||||
@ -8,13 +8,71 @@
|
||||
import Foundation
|
||||
|
||||
protocol SourceAccessMethod: Sendable {
|
||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot
|
||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async
|
||||
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
|
||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
|
||||
nonisolated func discoverItems(
|
||||
for source: MinecraftSource,
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem]
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry]
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async
|
||||
}
|
||||
|
||||
extension SourceAccessMethod {
|
||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
||||
_ = preparedScanRoot
|
||||
nonisolated var accessorIdentifier: SourceAccessorIdentifier {
|
||||
String(reflecting: Self.self)
|
||||
}
|
||||
|
||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||
SourceAccessDescriptor(
|
||||
accessorIdentifier: accessorIdentifier,
|
||||
kind: source.origin.kind,
|
||||
capabilities: source.origin.defaultCapabilities,
|
||||
refreshStrategy: source.origin.defaultRefreshStrategy
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||
_ = source
|
||||
return .unknown
|
||||
}
|
||||
|
||||
nonisolated func discoverItems(
|
||||
for source: MinecraftSource,
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem] {
|
||||
_ = source
|
||||
_ = onDiscovered
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return item
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return item
|
||||
}
|
||||
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||
_ = source
|
||||
_ = item
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||
_ = source
|
||||
return item.folderURL
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
_ = source
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,29 +82,72 @@ protocol ConnectedDeviceSourceAccessMethod: SourceAccessMethod {
|
||||
}
|
||||
|
||||
struct SourceAccessCoordinator: SourceAccessMethod {
|
||||
private let localFolderAccess: SourceAccessMethod
|
||||
private let connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
||||
private let accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod]
|
||||
|
||||
nonisolated init(
|
||||
localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||
connectedDeviceAccess: ConnectedDeviceSourceAccessMethod
|
||||
) {
|
||||
self.localFolderAccess = localFolderAccess
|
||||
self.connectedDeviceAccess = connectedDeviceAccess
|
||||
self.init(accessMethods: [localFolderAccess, connectedDeviceAccess])
|
||||
}
|
||||
|
||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return try await localFolderAccess.prepareScanRoot(for: source)
|
||||
case .connectedDevice:
|
||||
return try await connectedDeviceAccess.prepareScanRoot(for: source)
|
||||
nonisolated init(accessMethods: [any SourceAccessMethod]) {
|
||||
var accessMethodsByIdentifier: [SourceAccessorIdentifier: any SourceAccessMethod] = [:]
|
||||
for accessMethod in accessMethods {
|
||||
accessMethodsByIdentifier[accessMethod.accessorIdentifier] = accessMethod
|
||||
}
|
||||
self.accessMethodsByIdentifier = accessMethodsByIdentifier
|
||||
}
|
||||
|
||||
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
|
||||
await localFolderAccess.releaseScanRoot(preparedScanRoot)
|
||||
await connectedDeviceAccess.releaseScanRoot(preparedScanRoot)
|
||||
nonisolated private func accessMethod(for source: MinecraftSource) -> (any SourceAccessMethod) {
|
||||
if let accessMethod = accessMethodsByIdentifier[source.accessDescriptor.accessorIdentifier] {
|
||||
return accessMethod
|
||||
}
|
||||
|
||||
if let accessMethod = accessMethodsByIdentifier[source.origin.defaultAccessorIdentifier] {
|
||||
return accessMethod
|
||||
}
|
||||
|
||||
if let accessMethod = accessMethodsByIdentifier[LocalFolderSourceAccess().accessorIdentifier] {
|
||||
return accessMethod
|
||||
}
|
||||
|
||||
fatalError("No source access method is registered for \(source.accessDescriptor.accessorIdentifier).")
|
||||
}
|
||||
|
||||
nonisolated func discoverItems(
|
||||
for source: MinecraftSource,
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem] {
|
||||
return try await accessMethod(for: source).discoverItems(for: source, onDiscovered: onDiscovered)
|
||||
}
|
||||
|
||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||
accessMethod(for: source).accessDescriptor(for: source)
|
||||
}
|
||||
|
||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||
return await accessMethod(for: source).availability(for: source)
|
||||
}
|
||||
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||
return await accessMethod(for: source).enrich(item, for: source)
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
return await accessMethod(for: source).loadSize(for: item, in: source)
|
||||
}
|
||||
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||
return try await accessMethod(for: source).listItemContents(for: item, in: source)
|
||||
}
|
||||
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||
return try await accessMethod(for: source).materializeItem(for: item, in: source)
|
||||
}
|
||||
|
||||
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
|
||||
await accessMethod(for: source).purgeCachedArtifacts(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,9 +8,46 @@
|
||||
import Foundation
|
||||
|
||||
struct LocalFolderSourceAccess: SourceAccessMethod {
|
||||
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "local-folder"
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot {
|
||||
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||
_ = source
|
||||
return SourceAccessDescriptor(
|
||||
accessorIdentifier: accessorIdentifier,
|
||||
kind: .localFolder,
|
||||
capabilities: .localFolder,
|
||||
refreshStrategy: .eagerFullScan
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
|
||||
let candidateURL: URL
|
||||
if case .localFolder(let bookmarkData) = source.origin,
|
||||
let bookmarkData {
|
||||
var isStale = false
|
||||
if let resolvedURL = try? URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
) {
|
||||
candidateURL = resolvedURL.standardizedFileURL
|
||||
} else {
|
||||
candidateURL = source.folderURL
|
||||
}
|
||||
} else {
|
||||
candidateURL = source.folderURL
|
||||
}
|
||||
|
||||
return FileManager.default.fileExists(atPath: candidateURL.path) ? .available : .unavailable
|
||||
}
|
||||
|
||||
nonisolated func discoverItems(
|
||||
for source: MinecraftSource,
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem] {
|
||||
guard case .localFolder(let bookmarkData) = source.origin else {
|
||||
throw SourceAccessError.accessFailed(
|
||||
reason: "No local-folder access method is configured for this source type."
|
||||
@ -36,11 +73,51 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
|
||||
resolvedURL = source.folderURL
|
||||
}
|
||||
|
||||
return PreparedScanRoot(
|
||||
sourceID: source.id,
|
||||
rootURL: resolvedURL,
|
||||
mountPointURL: nil,
|
||||
cleanupBehavior: .none
|
||||
let accessedSecurityScope = resolvedURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessedSecurityScope {
|
||||
resolvedURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
|
||||
}
|
||||
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return await WorldScanner.enrich(item: item)
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return WorldScanner.loadSize(for: item)
|
||||
}
|
||||
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
|
||||
_ = source
|
||||
let fileManager = FileManager.default
|
||||
let urls = try fileManager.contentsOfDirectory(
|
||||
at: item.folderURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
|
||||
return urls
|
||||
.map { url in
|
||||
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||
return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory)
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.isDirectory != rhs.isDirectory {
|
||||
return lhs.isDirectory && !rhs.isDirectory
|
||||
}
|
||||
|
||||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
|
||||
_ = source
|
||||
return item.folderURL
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user