Refactor, support lightweight device scanning

This commit is contained in:
John Burwell 2026-05-26 22:28:20 -05:00
parent aca5baa155
commit ab6661d66b
20 changed files with 2559 additions and 250 deletions

View File

@ -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
await MainActor.run {
directoryPreviewContents = Array(contents.prefix(directoryPreviewLimit))
}
.prefix(directoryPreviewLimit)
.map { $0 }
}
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) {
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) {

View File

@ -612,6 +612,10 @@ struct ItemDetailView: View {
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
}
if item.sizeLoaded {
return "Unavailable"
}
return item.metadataLoaded ? "Calculating..." : "Loading..."
}

View File

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

View File

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

View File

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

View 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?
}

View File

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

View File

@ -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,13 +104,22 @@ 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)
do {
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 {
@ -119,7 +128,6 @@ enum ContentPackageExporter {
}
}
do {
let contents = try fileManager.contentsOfDirectory(
at: item.folderURL,
includingPropertiesForKeys: nil,
@ -130,6 +138,7 @@ enum ContentPackageExporter {
let destinationURL = stagingDirectoryURL.appendingPathComponent(entryURL.lastPathComponent)
try fileManager.copyItem(at: entryURL, to: destinationURL)
}
}
} catch {
throw ExportError.failedToPrepareArchiveContents(
"Could not prepare the item for archiving: \(error.localizedDescription)"
@ -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,

View File

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

View File

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

View File

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

View File

@ -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) {
if !localSources.isEmpty {
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?)
}
ForEach(localSources) { source in
sourceSectionRows(for: source)
}
} header: {
SidebarSourcesSectionHeaderView()
SidebarSourcesSectionHeaderView(title: "Libraries")
}
}
if !connectedDevices.isEmpty {
Section {
ForEach(connectedDevices) { entry in
connectedDeviceSectionRows(for: entry)
}
} header: {
SidebarSourcesSectionHeaderView(title: "Connected Devices")
}
}
}
.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

View File

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

View File

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

View File

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

View File

@ -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)
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
bundleIdentifier: container.appID,
relativePath: requestedSubpath
)
let items = summaries.compactMap { summary in
makeItem(from: summary, source: source)
}
for item in items {
onDiscovered(item)
}
return items
}
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 fileManager.createDirectory(at: mirrorURL, withIntermediateDirectories: true)
try await AppleMobileDeviceAccess.mirrorSubtree(
bundleIdentifier: container.appID,
relativePath: requestedSubpath,
destinationDirectoryURL: mirrorURL
relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL
)
return destinationURL
} catch {
try? fileManager.removeItem(at: mirrorURL)
throw SourceAccessError.accessFailed(reason: error.localizedDescription)
try? FileManager.default.removeItem(at: destinationURL)
throw error
}
}
return PreparedScanRoot(
sourceID: source.id,
rootURL: mirrorURL,
mountPointURL: mirrorURL,
cleanupBehavior: .deleteTemporaryDirectory
)
}
nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async {
guard case .deleteTemporaryDirectory = preparedScanRoot.cleanupBehavior,
let mountPointURL = preparedScanRoot.mountPointURL else {
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
}
}
}

View File

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

View File

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

View File

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

View File

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