diff --git a/Scripts/run_mobile_device_probe.sh b/Scripts/run_mobile_device_probe.sh new file mode 100755 index 0000000..a2d8e44 --- /dev/null +++ b/Scripts/run_mobile_device_probe.sh @@ -0,0 +1,18 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="/tmp/wmm-mobile-device-probe" +BINARY_PATH="$BUILD_DIR/mobile_device_probe" + +mkdir -p "$BUILD_DIR" + +xcrun clang \ + -fobjc-arc \ + -framework Foundation \ + -I"$ROOT_DIR/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice" \ + "$ROOT_DIR/Tools/mobile_device_probe.m" \ + "$ROOT_DIR/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m" \ + -o "$BINARY_PATH" + +exec "$BINARY_PATH" "$@" diff --git a/Tools/mobile_device_probe.m b/Tools/mobile_device_probe.m new file mode 100644 index 0000000..8a61efe --- /dev/null +++ b/Tools/mobile_device_probe.m @@ -0,0 +1,174 @@ +#import +#import "AppleMobileDeviceBridge.h" + +static void PrintUsage(void) { + fprintf(stderr, "Usage:\n"); + fprintf(stderr, " mobile_device_probe summary\n"); + fprintf(stderr, " mobile_device_probe apps\n"); + fprintf(stderr, " mobile_device_probe details \n"); + fprintf(stderr, " mobile_device_probe list \n"); + fprintf(stderr, " mobile_device_probe probe-paths [path ...]\n"); + fprintf(stderr, " mobile_device_probe mirror \n"); +} + +static NSArray *DefaultCandidatePaths(void) { + return @[ + @"", + @".", + @"./", + @"/", + @"Documents", + @"Documents/", + @"/Documents", + @"games", + @"games/", + @"/games", + @"games/com.mojang", + @"games/com.mojang/", + @"/games/com.mojang", + @"Documents/games", + @"Documents/games/", + @"/Documents/games", + @"Documents/games/com.mojang", + @"Documents/games/com.mojang/", + @"/Documents/games/com.mojang", + @"minecraftWorlds", + @"minecraftWorlds/", + @"/minecraftWorlds", + @"Documents/minecraftWorlds", + @"Documents/minecraftWorlds/", + @"/Documents/minecraftWorlds" + ]; +} + +static NSString *JSONString(id object) { + if (!object) { + return @"null"; + } + + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:object options:NSJSONWritingPrettyPrinted error:&error]; + if (!data || error) { + return [NSString stringWithFormat:@"", error.localizedDescription ?: @"unknown"]; + } + + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; +} + +static int PrintErrorAndReturn(NSError *error) { + fprintf(stderr, "Error: %s\n", error.localizedDescription.UTF8String ?: "unknown"); + return 1; +} + +int main(int argc, const char * argv[]) { + @autoreleasepool { + if (argc < 2) { + PrintUsage(); + return 2; + } + + NSString *command = [NSString stringWithUTF8String:argv[1]]; + NSError *error = nil; + + if ([command isEqualToString:@"summary"]) { + NSDictionary *summary = WMMCopyFirstConnectedDeviceSummary(&error); + if (!summary) { + return PrintErrorAndReturn(error); + } + printf("%s\n", JSONString(summary).UTF8String); + return 0; + } + + if ([command isEqualToString:@"apps"]) { + NSDictionary *apps = WMMCopyFirstConnectedDeviceApplicationList(&error); + if (!apps) { + return PrintErrorAndReturn(error); + } + printf("%s\n", JSONString(apps).UTF8String); + return 0; + } + + if ([command isEqualToString:@"details"]) { + if (argc < 3) { + PrintUsage(); + return 2; + } + + NSString *bundleIdentifier = [NSString stringWithUTF8String:argv[2]]; + NSDictionary *details = WMMCopyFirstConnectedDeviceApplicationDetails(bundleIdentifier, &error); + if (!details) { + return PrintErrorAndReturn(error); + } + printf("%s\n", JSONString(details).UTF8String); + return 0; + } + + if ([command isEqualToString:@"list"]) { + if (argc < 4) { + PrintUsage(); + return 2; + } + + NSString *bundleIdentifier = [NSString stringWithUTF8String:argv[2]]; + NSString *path = [NSString stringWithUTF8String:argv[3]]; + NSDictionary *listing = WMMCopyFirstConnectedDeviceAppDirectoryListing(bundleIdentifier, path, &error); + if (!listing) { + return PrintErrorAndReturn(error); + } + printf("%s\n", JSONString(listing).UTF8String); + return 0; + } + + if ([command isEqualToString:@"probe-paths"]) { + if (argc < 3) { + PrintUsage(); + return 2; + } + + NSString *bundleIdentifier = [NSString stringWithUTF8String:argv[2]]; + NSMutableArray *paths = [NSMutableArray array]; + if (argc > 3) { + for (int index = 3; index < argc; index += 1) { + [paths addObject:[NSString stringWithUTF8String:argv[index]]]; + } + } else { + [paths addObjectsFromArray:DefaultCandidatePaths()]; + } + + NSDictionary *probeResults = WMMCopyFirstConnectedDeviceAppPathProbeResults(bundleIdentifier, paths, &error); + if (!probeResults) { + return PrintErrorAndReturn(error); + } + printf("%s\n", JSONString(probeResults).UTF8String); + return 0; + } + + if ([command isEqualToString:@"mirror"]) { + if (argc < 5) { + PrintUsage(); + return 2; + } + + NSString *bundleIdentifier = [NSString stringWithUTF8String:argv[2]]; + NSString *path = [NSString stringWithUTF8String:argv[3]]; + NSString *destinationPath = [NSString stringWithUTF8String:argv[4]]; + NSURL *destinationURL = [NSURL fileURLWithPath:destinationPath isDirectory:YES]; + + BOOL didCopy = WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( + bundleIdentifier, + path, + destinationURL, + &error + ); + if (!didCopy) { + return PrintErrorAndReturn(error); + } + + printf("%s\n", destinationURL.path.UTF8String); + return 0; + } + + PrintUsage(); + return 2; + } +} diff --git a/World Manager for Minecraft.xcodeproj/project.pbxproj b/World Manager for Minecraft.xcodeproj/project.pbxproj index 66a406a..f3c846d 100644 --- a/World Manager for Minecraft.xcodeproj/project.pbxproj +++ b/World Manager for Minecraft.xcodeproj/project.pbxproj @@ -395,7 +395,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; @@ -413,6 +413,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; @@ -444,6 +445,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index d2483bc..86a26f3 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -10,17 +10,33 @@ import SwiftUI import UniformTypeIdentifiers struct ContentView: View { - @StateObject private var library = SourceLibrary() + @StateObject private var library: SourceLibrary @State private var selectedItemID: MinecraftContentItem.ID? @State private var selectedSidebarSelection: SidebarSelection? @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var searchText = "" @State private var isDropTargeted = false @State private var isPerformingItemAction = false + @State private var isShowingDeviceSourceSheet = false @State private var sortMode: ItemSortMode = .name + private let connectedDeviceAccess: AppleMobileDeviceSourceAccess + private let deviceSourceFactory: ConnectedDeviceSourceFactory private let directoryPreviewLimit = 12 + init() { + let connectedDeviceAccess = AppleMobileDeviceSourceAccess() + self.connectedDeviceAccess = connectedDeviceAccess + self.deviceSourceFactory = ConnectedDeviceSourceFactory() + _library = StateObject( + wrappedValue: SourceLibrary( + sourceAccessMethod: SourceAccessCoordinator( + connectedDeviceAccess: connectedDeviceAccess + ) + ) + ) + } + var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SourcesSidebarView( @@ -28,6 +44,7 @@ struct ContentView: View { selection: $selectedSidebarSelection, footerState: library.sidebarFooterState, addSourceAction: pickFolder, + addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, rescanSourceAction: { source in selectedSidebarSelection = .allContent(sourceID: source.id) selectedItemID = nil @@ -104,6 +121,18 @@ struct ContentView: View { LaunchRestoreOverlayView() } } + .sheet(isPresented: $isShowingDeviceSourceSheet) { + ConnectedDeviceSourcePickerView( + deviceDiscoveryService: connectedDeviceAccess, + sourceFactory: deviceSourceFactory, + onAddSource: { source in + let sourceID = library.addSource(source, shouldPersist: false, shouldScan: true) + selectedSidebarSelection = .allContent(sourceID: sourceID) + selectedItemID = nil + isShowingDeviceSourceSheet = false + } + ) + } .disabled(library.isRestoringPersistedSources) .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 634b1b3..f6d11de 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -27,17 +27,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var indexedDetailCount: Int var lastScanDate: Date? - init( + nonisolated init( + sourceID: URL? = nil, folderURL: URL, bookmarkData: Data? = nil, origin: MinecraftSourceOrigin? = nil ) { - let normalizedURL = folderURL.standardizedFileURL - self.id = normalizedURL - self.folderURL = normalizedURL + let normalizedFolderURL = normalizedSourceURL(folderURL) + self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL) + self.folderURL = normalizedFolderURL self.origin = origin ?? .localFolder(bookmarkData: bookmarkData) self.bookmarkData = bookmarkData - self.displayName = normalizedURL.lastPathComponent + self.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] self.rawItems = [] self.logicalPacks = [] @@ -117,6 +118,14 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { } } +nonisolated private func normalizedSourceURL(_ url: URL) -> URL { + if url.isFileURL { + return url.standardizedFileURL + } + + return url.standardized +} + private extension Array { func uniqued(by keyPath: KeyPath) -> [Element] { var seen = Set() diff --git a/World Manager for Minecraft/Models/SourceOrigin.swift b/World Manager for Minecraft/Models/SourceOrigin.swift index 8f13a5b..8f71b75 100644 --- a/World Manager for Minecraft/Models/SourceOrigin.swift +++ b/World Manager for Minecraft/Models/SourceOrigin.swift @@ -69,10 +69,12 @@ enum MinecraftSourceKind: String, Hashable, Sendable, Codable { 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 } } diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index 2b7750c..9cc90a1 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -294,6 +294,7 @@ struct SidebarColumnPreviewContainer: View { selection: $selection, footerState: PreviewFixtures.sidebarFooter, addSourceAction: {}, + addDeviceSourceAction: {}, rescanSourceAction: { _ in }, removeSourceAction: { _ in }, revealFooterURLAction: { _ in }, diff --git a/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift b/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift deleted file mode 100644 index 4cff3c3..0000000 --- a/World Manager for Minecraft/Services/DeviceAccessCoordinator.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// DeviceAccessCoordinator.swift -// World Manager for Minecraft -// -// Created by OpenAI on 2026-05-26. -// - -import Foundation - -protocol SourceScanRootPreparing: Sendable { - nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot -} - -protocol DeviceDiscoveryServing: Sendable { - nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] - nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] -} - -protocol DeviceMountServing: Sendable { - nonisolated func prepareScanRoot( - for source: MinecraftSource, - preferredSubpath: String? - ) async throws -> PreparedScanRoot -} - -struct LocalFolderScanRootPreparer: SourceScanRootPreparing { - nonisolated init() {} - - nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot { - guard case .localFolder(let bookmarkData) = source.origin else { - throw DeviceAccessError.mountFailed( - reason: "No scan-root preparer is configured for this source type." - ) - } - - let resolvedURL: URL - if let bookmarkData { - var isStale = false - guard let bookmarkURL = try? URL( - resolvingBookmarkData: bookmarkData, - options: [.withSecurityScope], - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) else { - throw DeviceAccessError.mountFailed( - reason: "The saved folder bookmark could not be resolved." - ) - } - - resolvedURL = bookmarkURL.standardizedFileURL - } else { - resolvedURL = source.folderURL - } - - return PreparedScanRoot( - sourceID: source.id, - rootURL: resolvedURL, - cleanupBehavior: .none - ) - } -} - -enum DeviceAccessError: LocalizedError, Sendable { - case toolingUnavailable - case deviceUnavailable - case deviceNotTrusted - case appNotAccessible(appID: String) - case minecraftFolderMissing(appID: String) - case mountFailed(reason: String) - - var errorDescription: String? { - switch self { - case .toolingUnavailable: - return "Required device-access tooling is unavailable." - case .deviceUnavailable: - return "The selected device is no longer available." - case .deviceNotTrusted: - return "The device must be unlocked and trusted before its files can be accessed." - case .appNotAccessible(let appID): - return "The app container for \(appID) is not accessible on this device." - case .minecraftFolderMissing(let appID): - return "Minecraft resources were not found in the accessible container for \(appID)." - case .mountFailed(let reason): - return reason - } - } -} diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 78946cb..385fc1e 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -42,14 +42,14 @@ final class SourceLibrary: ObservableObject { private var scanTasks: [URL: Task] = [:] private var footerResetTask: Task? private let persistenceStore: SourcePersistenceStore - private let scanRootPreparer: SourceScanRootPreparing + private let sourceAccessMethod: SourceAccessMethod init( persistenceStore: SourcePersistenceStore = .shared, - scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer() + sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess() ) { self.persistenceStore = persistenceStore - self.scanRootPreparer = scanRootPreparer + self.sourceAccessMethod = sourceAccessMethod Task { [weak self] in await self?.restorePersistedSources() @@ -70,11 +70,35 @@ final class SourceLibrary: ObservableObject { return normalizedURL } - sources.append(MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)) - sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } - persistSourceIfAvailable(withID: normalizedURL) - startScan(for: normalizedURL) - return normalizedURL + let source = MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData) + return addSource(source, shouldPersist: true, shouldScan: true) + } + + @discardableResult + func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL { + if sources.contains(where: { $0.id == source.id }) { + updateSource(source.id) { existingSource in + existingSource.origin = source.origin + if existingSource.bookmarkData == nil { + existingSource.bookmarkData = source.bookmarkData + } + if existingSource.displayName.isEmpty { + existingSource.displayName = source.displayName + } + } + } else { + sources.append(source) + sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + } + + if shouldPersist, source.origin.kind == .localFolder { + persistSourceIfAvailable(withID: source.id) + } + if shouldScan { + startScan(for: source.id) + } + + return source.id } func source(withID sourceID: URL) -> MinecraftSource? { @@ -169,7 +193,7 @@ final class SourceLibrary: ObservableObject { let preparedScanRoot: PreparedScanRoot do { - preparedScanRoot = try await scanRootPreparer.prepareScanRoot(for: source) + preparedScanRoot = try await sourceAccessMethod.prepareScanRoot(for: source) } catch { updateSource(sourceID) { source in source.scanError = error.localizedDescription @@ -944,11 +968,8 @@ final class SourceLibrary: ObservableObject { } private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) { - switch preparedScanRoot.cleanupBehavior { - case .none: - return - case .unmount: - return + Task.detached(priority: .utility) { [sourceAccessMethod] in + await sourceAccessMethod.releaseScanRoot(preparedScanRoot) } } diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index a2d4de3..ba5d3f3 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -25,6 +25,7 @@ struct SourcesSidebarView: View { @Binding var selection: SidebarSelection? let footerState: SidebarFooterState let addSourceAction: () -> Void + let addDeviceSourceAction: () -> Void let rescanSourceAction: (MinecraftSource) -> Void let removeSourceAction: (MinecraftSource) -> Void let revealFooterURLAction: (URL) -> Void @@ -77,6 +78,13 @@ struct SourcesSidebarView: View { } .help("Add Source Folder") } + + ToolbarItem { + Button(action: addDeviceSourceAction) { + Image(systemName: "iphone.gen3") + } + .help("Add Connected Device Source") + } } .animation(.easeInOut(duration: 0.2), value: footerState.style) } diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift new file mode 100644 index 0000000..63ae24e --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift @@ -0,0 +1,122 @@ +// +// AppleMobileDeviceAccess.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +struct AppleMobileDeviceSummary: Sendable { + let deviceName: String + let deviceIdentifier: String + let productType: String + let productVersion: String + let trustState: DeviceTrustState +} + +struct AppleMobileDeviceApplicationSummary: Sendable { + let bundleIdentifier: String + let displayName: String + let fileSharingEnabled: Bool + let supportsOpeningDocumentsInPlace: Bool +} + +enum AppleMobileDeviceAccess { + static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary { + try await Task.detached(priority: .userInitiated) { + var error: NSError? + guard let response = WMMCopyFirstConnectedDeviceSummary(&error) else { + throw error ?? NSError( + domain: "AppleMobileDeviceAccess", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No connected device could be read from MobileDevice.framework."] + ) + } + + guard + let deviceName = response["deviceName"] as? String, + let deviceIdentifier = response["deviceIdentifier"] as? String, + let productType = response["productType"] as? String, + let productVersion = response["productVersion"] as? String, + let trustStateRawValue = response["trustState"] as? String, + let trustState = DeviceTrustState(rawValue: trustStateRawValue) + else { + throw NSError( + domain: "AppleMobileDeviceAccess", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice summary returned an unexpected payload."] + ) + } + + return AppleMobileDeviceSummary( + deviceName: deviceName, + deviceIdentifier: deviceIdentifier, + productType: productType, + productVersion: productVersion, + trustState: trustState + ) + }.value + } + + static func mirrorSubtree( + bundleIdentifier: String, + relativePath: String, + destinationDirectoryURL: URL + ) async throws { + try await Task.detached(priority: .userInitiated) { + var error: NSError? + let didCopy = WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( + bundleIdentifier, + relativePath, + destinationDirectoryURL, + &error + ) + + if !didCopy { + throw error ?? NSError( + domain: "AppleMobileDeviceAccess", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."] + ) + } + }.value + } + + static func listApplications() async throws -> [AppleMobileDeviceApplicationSummary] { + try await Task.detached(priority: .userInitiated) { + var error: NSError? + guard let response = WMMCopyFirstConnectedDeviceApplicationList(&error) else { + throw error ?? NSError( + domain: "AppleMobileDeviceAccess", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing failed."] + ) + } + + guard let rawApplications = response["applications"] as? [[String: Any]] else { + throw NSError( + domain: "AppleMobileDeviceAccess", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing returned an unexpected payload."] + ) + } + + return rawApplications.compactMap { application in + guard + let bundleIdentifier = application["bundleIdentifier"] as? String, + let displayName = application["displayName"] as? String + else { + return nil + } + + return AppleMobileDeviceApplicationSummary( + bundleIdentifier: bundleIdentifier, + displayName: displayName, + fileSharingEnabled: application["uiFileSharingEnabled"] as? Bool ?? false, + supportsOpeningDocumentsInPlace: application["supportsOpeningDocumentsInPlace"] as? Bool ?? false + ) + } + }.value + } +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h new file mode 100644 index 0000000..0bf78e1 --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h @@ -0,0 +1,48 @@ +// +// AppleMobileDeviceBridge.h +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain; + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceSummary(NSError **error); + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceApplicationList(NSError **error); + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceApplicationDetails( + NSString *bundleIdentifier, + NSError **error +); + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceAppDirectoryListing( + NSString *bundleIdentifier, + NSString *relativePath, + NSError **error +); + +FOUNDATION_EXPORT NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceAppPathProbeResults( + NSString *bundleIdentifier, + NSArray *paths, + NSError **error +); + +FOUNDATION_EXPORT BOOL +WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( + NSString *bundleIdentifier, + NSString *relativePath, + NSURL *destinationDirectoryURL, + NSError **error +); + +NS_ASSUME_NONNULL_END diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m new file mode 100644 index 0000000..f18316d --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m @@ -0,0 +1,1167 @@ +// +// AppleMobileDeviceBridge.m +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +#import "AppleMobileDeviceBridge.h" + +#import +#import + +NSErrorDomain const WMMMobileDeviceErrorDomain = @"WMMMobileDeviceErrorDomain"; + +typedef struct am_device *AMDeviceRef; +typedef struct am_device_notification *AMDeviceNotificationRef; +typedef struct amd_service_connection *AMDServiceConnectionRef; +typedef struct afc_connection *AFCConnectionRef; +typedef struct afc_directory *AFCDirectoryRef; +typedef CFTypeRef AFCFileDescriptorRef; +typedef CFTypeRef AFCIteratorRef; +typedef uint32_t service_conn_t; + +struct am_device_notification_callback_info { + AMDeviceRef dev; + unsigned int msg; +}; + +typedef void (*AMDeviceNotificationCallback)( + struct am_device_notification_callback_info *info, + void *context +); + +enum { + WMMAMDConnectedMessage = 1 +}; + +typedef int (*AMDeviceNotificationSubscribeFn)( + AMDeviceNotificationCallback callback, + int unused1, + int unused2, + void *context, + AMDeviceNotificationRef *subscription +); +typedef int (*AMDeviceNotificationUnsubscribeFn)(AMDeviceNotificationRef subscription); +typedef AMDeviceRef (*AMDeviceRetainFn)(AMDeviceRef device); +typedef int (*AMDeviceReleaseFn)(AMDeviceRef device); +typedef int (*AMDeviceConnectFn)(AMDeviceRef device); +typedef int (*AMDeviceDisconnectFn)(AMDeviceRef device); +typedef int (*AMDeviceIsPairedFn)(AMDeviceRef device); +typedef int (*AMDeviceValidatePairingFn)(AMDeviceRef device); +typedef int (*AMDeviceStartSessionFn)(AMDeviceRef device); +typedef int (*AMDeviceStopSessionFn)(AMDeviceRef device); +typedef CFStringRef _Nullable (*AMDeviceCopyDeviceIdentifierFn)(AMDeviceRef device); +typedef CFTypeRef _Nullable (*AMDeviceCopyValueFn)(AMDeviceRef device, CFStringRef _Nullable domain, CFStringRef name); +typedef int (*AMDeviceLookupApplicationsFn)( + AMDeviceRef device, + CFDictionaryRef _Nullable options, + CFDictionaryRef _Nullable *result +); +typedef int (*AMDeviceCreateHouseArrestServiceFn)( + AMDeviceRef device, + CFStringRef identifier, + CFDictionaryRef _Nullable options, + AFCConnectionRef _Nullable *connection +); +typedef int (*AMDeviceSecureStartServiceFn)( + AMDeviceRef device, + CFStringRef serviceName, + CFDictionaryRef _Nullable options, + AMDServiceConnectionRef _Nullable *serviceConnection +); +typedef int (*AMDeviceStartHouseArrestServiceFn)( + AMDeviceRef device, + CFStringRef identifier, + CFDictionaryRef _Nullable options, + service_conn_t *handle, + void * _Nullable *secureContext +); +typedef int (*AMDServiceConnectionSendMessageFn)( + AMDServiceConnectionRef serviceConnection, + CFPropertyListRef message, + int timeout +); +typedef int (*AMDServiceConnectionReceiveMessageFn)( + AMDServiceConnectionRef serviceConnection, + CFPropertyListRef _Nullable *message, + int timeout +); +typedef void (*AMDServiceConnectionInvalidateFn)(AMDServiceConnectionRef serviceConnection); +typedef int (*AMDServiceConnectionGetSocketFn)(AMDServiceConnectionRef serviceConnection); +typedef void * _Nullable (*AMDServiceConnectionGetSecureIOContextFn)(AMDServiceConnectionRef serviceConnection); +typedef AFCConnectionRef _Nullable (*AFCConnectionCreateFn)( + CFAllocatorRef allocator, + int socket, + uint32_t unused1, + void * _Nullable unused2, + void * _Nullable unused3 +); +typedef void (*AFCConnectionSetSecureContextFn)(AFCConnectionRef connection, void * _Nullable secureContext); +typedef int (*AFCConnectionOpenFn)(service_conn_t handle, unsigned int ioTimeout, AFCConnectionRef *connection); +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 (*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); + +typedef struct { + void *handle; + AMDeviceNotificationSubscribeFn AMDeviceNotificationSubscribe; + AMDeviceNotificationUnsubscribeFn AMDeviceNotificationUnsubscribe; + AMDeviceRetainFn AMDeviceRetain; + AMDeviceReleaseFn AMDeviceRelease; + AMDeviceConnectFn AMDeviceConnect; + AMDeviceDisconnectFn AMDeviceDisconnect; + AMDeviceIsPairedFn AMDeviceIsPaired; + AMDeviceValidatePairingFn AMDeviceValidatePairing; + AMDeviceStartSessionFn AMDeviceStartSession; + AMDeviceStopSessionFn AMDeviceStopSession; + AMDeviceCopyDeviceIdentifierFn AMDeviceCopyDeviceIdentifier; + AMDeviceCopyValueFn AMDeviceCopyValue; + AMDeviceLookupApplicationsFn AMDeviceLookupApplications; + AMDeviceCreateHouseArrestServiceFn AMDeviceCreateHouseArrestService; + AMDeviceSecureStartServiceFn AMDeviceSecureStartService; + AMDeviceStartHouseArrestServiceFn AMDeviceStartHouseArrestService; + AMDServiceConnectionSendMessageFn AMDServiceConnectionSendMessage; + AMDServiceConnectionReceiveMessageFn AMDServiceConnectionReceiveMessage; + AMDServiceConnectionInvalidateFn AMDServiceConnectionInvalidate; + AMDServiceConnectionGetSocketFn AMDServiceConnectionGetSocket; + AMDServiceConnectionGetSecureIOContextFn AMDServiceConnectionGetSecureIOContext; + AFCConnectionCreateFn AFCConnectionCreate; + AFCConnectionSetSecureContextFn AFCConnectionSetSecureContext; + AFCConnectionOpenFn AFCConnectionOpen; + AFCConnectionCloseFn AFCConnectionClose; + AFCDirectoryOpenFn AFCDirectoryOpen; + AFCDirectoryReadFn AFCDirectoryRead; + AFCDirectoryCloseFn AFCDirectoryClose; + AFCFileRefOpenFn AFCFileRefOpen; + AFCFileRefReadFn AFCFileRefRead; + AFCFileRefCloseFn AFCFileRefClose; +} WMMMobileDeviceFunctions; + +typedef struct { + WMMMobileDeviceFunctions *functions; + CFRunLoopRef runLoop; + AMDeviceRef device; +} WMMDeviceWaitContext; + +static NSError *WMMMakeError(NSInteger code, NSString *description) { + return [NSError errorWithDomain:WMMMobileDeviceErrorDomain code:code userInfo:@{ + NSLocalizedDescriptionKey: description + }]; +} + +static void *WMMLoadSymbol(void *handle, const char *name) { + return dlsym(handle, name); +} + +static BOOL WMMLoadFunctions(WMMMobileDeviceFunctions *functions, NSError **error) { + static const char *paths[] = { + "/Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/MobileDevice", + "/System/Library/PrivateFrameworks/MobileDevice.framework/MobileDevice" + }; + + void *frameworkHandle = NULL; + for (size_t index = 0; index < sizeof(paths) / sizeof(paths[0]); index += 1) { + frameworkHandle = dlopen(paths[index], RTLD_NOW | RTLD_LOCAL); + if (frameworkHandle != NULL) { + break; + } + } + + if (frameworkHandle == NULL) { + if (error != NULL) { + *error = WMMMakeError(1, @"MobileDevice.framework is not available."); + } + return NO; + } + + memset(functions, 0, sizeof(*functions)); + functions->handle = frameworkHandle; + functions->AMDeviceNotificationSubscribe = (AMDeviceNotificationSubscribeFn)WMMLoadSymbol(frameworkHandle, "AMDeviceNotificationSubscribe"); + functions->AMDeviceNotificationUnsubscribe = (AMDeviceNotificationUnsubscribeFn)WMMLoadSymbol(frameworkHandle, "AMDeviceNotificationUnsubscribe"); + functions->AMDeviceRetain = (AMDeviceRetainFn)WMMLoadSymbol(frameworkHandle, "AMDeviceRetain"); + functions->AMDeviceRelease = (AMDeviceReleaseFn)WMMLoadSymbol(frameworkHandle, "AMDeviceRelease"); + functions->AMDeviceConnect = (AMDeviceConnectFn)WMMLoadSymbol(frameworkHandle, "AMDeviceConnect"); + functions->AMDeviceDisconnect = (AMDeviceDisconnectFn)WMMLoadSymbol(frameworkHandle, "AMDeviceDisconnect"); + functions->AMDeviceIsPaired = (AMDeviceIsPairedFn)WMMLoadSymbol(frameworkHandle, "AMDeviceIsPaired"); + functions->AMDeviceValidatePairing = (AMDeviceValidatePairingFn)WMMLoadSymbol(frameworkHandle, "AMDeviceValidatePairing"); + functions->AMDeviceStartSession = (AMDeviceStartSessionFn)WMMLoadSymbol(frameworkHandle, "AMDeviceStartSession"); + functions->AMDeviceStopSession = (AMDeviceStopSessionFn)WMMLoadSymbol(frameworkHandle, "AMDeviceStopSession"); + functions->AMDeviceCopyDeviceIdentifier = (AMDeviceCopyDeviceIdentifierFn)WMMLoadSymbol(frameworkHandle, "AMDeviceCopyDeviceIdentifier"); + functions->AMDeviceCopyValue = (AMDeviceCopyValueFn)WMMLoadSymbol(frameworkHandle, "AMDeviceCopyValue"); + functions->AMDeviceLookupApplications = (AMDeviceLookupApplicationsFn)WMMLoadSymbol(frameworkHandle, "AMDeviceLookupApplications"); + functions->AMDeviceCreateHouseArrestService = (AMDeviceCreateHouseArrestServiceFn)WMMLoadSymbol(frameworkHandle, "AMDeviceCreateHouseArrestService"); + functions->AMDeviceSecureStartService = (AMDeviceSecureStartServiceFn)WMMLoadSymbol(frameworkHandle, "AMDeviceSecureStartService"); + functions->AMDeviceStartHouseArrestService = (AMDeviceStartHouseArrestServiceFn)WMMLoadSymbol(frameworkHandle, "AMDeviceStartHouseArrestService"); + functions->AMDServiceConnectionSendMessage = (AMDServiceConnectionSendMessageFn)WMMLoadSymbol(frameworkHandle, "AMDServiceConnectionSendMessage"); + functions->AMDServiceConnectionReceiveMessage = (AMDServiceConnectionReceiveMessageFn)WMMLoadSymbol(frameworkHandle, "AMDServiceConnectionReceiveMessage"); + functions->AMDServiceConnectionInvalidate = (AMDServiceConnectionInvalidateFn)WMMLoadSymbol(frameworkHandle, "AMDServiceConnectionInvalidate"); + functions->AMDServiceConnectionGetSocket = (AMDServiceConnectionGetSocketFn)WMMLoadSymbol(frameworkHandle, "AMDServiceConnectionGetSocket"); + functions->AMDServiceConnectionGetSecureIOContext = (AMDServiceConnectionGetSecureIOContextFn)WMMLoadSymbol(frameworkHandle, "AMDServiceConnectionGetSecureIOContext"); + functions->AFCConnectionCreate = (AFCConnectionCreateFn)WMMLoadSymbol(frameworkHandle, "AFCConnectionCreate"); + functions->AFCConnectionSetSecureContext = (AFCConnectionSetSecureContextFn)WMMLoadSymbol(frameworkHandle, "AFCConnectionSetSecureContext"); + functions->AFCConnectionOpen = (AFCConnectionOpenFn)WMMLoadSymbol(frameworkHandle, "AFCConnectionOpen"); + functions->AFCConnectionClose = (AFCConnectionCloseFn)WMMLoadSymbol(frameworkHandle, "AFCConnectionClose"); + functions->AFCDirectoryOpen = (AFCDirectoryOpenFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryOpen"); + functions->AFCDirectoryRead = (AFCDirectoryReadFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryRead"); + functions->AFCDirectoryClose = (AFCDirectoryCloseFn)WMMLoadSymbol(frameworkHandle, "AFCDirectoryClose"); + functions->AFCFileRefOpen = (AFCFileRefOpenFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefOpen"); + functions->AFCFileRefRead = (AFCFileRefReadFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefRead"); + functions->AFCFileRefClose = (AFCFileRefCloseFn)WMMLoadSymbol(frameworkHandle, "AFCFileRefClose"); + + if (functions->AMDeviceNotificationSubscribe == NULL || + functions->AMDeviceNotificationUnsubscribe == NULL || + functions->AMDeviceRetain == NULL || + functions->AMDeviceRelease == NULL || + functions->AMDeviceConnect == NULL || + functions->AMDeviceDisconnect == NULL || + functions->AMDeviceIsPaired == NULL || + functions->AMDeviceValidatePairing == NULL || + functions->AMDeviceStartSession == NULL || + functions->AMDeviceStopSession == NULL || + functions->AMDeviceCopyDeviceIdentifier == NULL || + functions->AMDeviceCopyValue == NULL || + functions->AMDeviceLookupApplications == NULL || + functions->AMDeviceCreateHouseArrestService == NULL || + functions->AMDeviceSecureStartService == NULL || + functions->AMDeviceStartHouseArrestService == NULL || + functions->AMDServiceConnectionSendMessage == NULL || + functions->AMDServiceConnectionReceiveMessage == NULL || + functions->AMDServiceConnectionInvalidate == NULL || + functions->AMDServiceConnectionGetSocket == NULL || + functions->AMDServiceConnectionGetSecureIOContext == NULL || + functions->AFCConnectionCreate == NULL || + functions->AFCConnectionSetSecureContext == NULL || + functions->AFCConnectionOpen == NULL || + functions->AFCConnectionClose == NULL || + functions->AFCDirectoryOpen == NULL || + functions->AFCDirectoryRead == NULL || + functions->AFCDirectoryClose == NULL || + functions->AFCFileRefOpen == NULL || + functions->AFCFileRefRead == NULL || + functions->AFCFileRefClose == NULL) { + if (error != NULL) { + *error = WMMMakeError(2, @"MobileDevice.framework symbols could not be loaded."); + } + dlclose(frameworkHandle); + memset(functions, 0, sizeof(*functions)); + return NO; + } + + return YES; +} + +static void WMMDeviceNotificationCallback(struct am_device_notification_callback_info *info, void *contextPointer) { + if (info == NULL || contextPointer == NULL || info->msg != WMMAMDConnectedMessage) { + return; + } + + WMMDeviceWaitContext *context = contextPointer; + if (context->device == NULL) { + context->device = context->functions->AMDeviceRetain(info->dev); + if (context->runLoop != NULL) { + CFRunLoopStop(context->runLoop); + } + } +} + +static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functions, NSError **error) { + WMMDeviceWaitContext context = { + .functions = functions, + .runLoop = CFRunLoopGetCurrent(), + .device = NULL + }; + + AMDeviceNotificationRef subscription = NULL; + const int subscribeStatus = functions->AMDeviceNotificationSubscribe( + WMMDeviceNotificationCallback, + 0, + 0, + &context, + &subscription + ); + if (subscribeStatus != 0) { + if (error != NULL) { + *error = WMMMakeError(3, @"No connected iPhone or iPad was detected through MobileDevice.framework."); + } + return NULL; + } + + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 2.0, false); + functions->AMDeviceNotificationUnsubscribe(subscription); + + if (context.device == NULL && error != NULL) { + *error = WMMMakeError(3, @"No connected iPhone or iPad was detected through MobileDevice.framework."); + } + + return context.device; +} + +static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key) { + if (functions->AMDeviceCopyValue == NULL) { + return nil; + } + + CFTypeRef value = functions->AMDeviceCopyValue(device, NULL, key); + if (value == NULL) { + return nil; + } + + if (CFGetTypeID(value) != CFStringGetTypeID()) { + CFRelease(value); + return nil; + } + + return CFBridgingRelease(value); +} + +static BOOL WMMConnectAndValidateDevice( + WMMMobileDeviceFunctions *functions, + AMDeviceRef device, + BOOL startSession, + NSError **error +) { + const int connectStatus = functions->AMDeviceConnect(device); + if (connectStatus != 0) { + if (error != NULL) { + *error = WMMMakeError(connectStatus, [NSString stringWithFormat:@"AMDeviceConnect failed (%d).", connectStatus]); + } + return NO; + } + + if (!functions->AMDeviceIsPaired(device)) { + functions->AMDeviceDisconnect(device); + if (error != NULL) { + *error = WMMMakeError(5, @"The connected device is not paired with this Mac."); + } + return NO; + } + + const int validateStatus = functions->AMDeviceValidatePairing(device); + if (validateStatus != 0) { + functions->AMDeviceDisconnect(device); + if (error != NULL) { + *error = WMMMakeError(validateStatus, [NSString stringWithFormat:@"AMDeviceValidatePairing failed (%d).", validateStatus]); + } + return NO; + } + + if (!startSession) { + return YES; + } + + const int sessionStatus = functions->AMDeviceStartSession(device); + if (sessionStatus != 0) { + functions->AMDeviceDisconnect(device); + if (error != NULL) { + *error = WMMMakeError(sessionStatus, [NSString stringWithFormat:@"AMDeviceStartSession failed (%d).", sessionStatus]); + } + return NO; + } + + return YES; +} + +static void WMMDisconnectDevice( + WMMMobileDeviceFunctions *functions, + AMDeviceRef device, + BOOL hadSession +) { + if (hadSession) { + functions->AMDeviceStopSession(device); + } + + functions->AMDeviceDisconnect(device); +} + +static AFCConnectionRef _Nullable WMMCreateAFCConnectionFromServiceConnection( + WMMMobileDeviceFunctions *functions, + AMDServiceConnectionRef serviceConnection +) { + int socket = functions->AMDServiceConnectionGetSocket(serviceConnection); + if (socket < 0) { + return NULL; + } + + AFCConnectionRef connection = functions->AFCConnectionCreate( + kCFAllocatorDefault, + socket, + 1, + NULL, + NULL + ); + if (connection == NULL) { + return NULL; + } + + void *secureContext = functions->AMDServiceConnectionGetSecureIOContext(serviceConnection); + if (secureContext != NULL) { + functions->AFCConnectionSetSecureContext(connection, secureContext); + } + + return connection; +} + +static AFCConnectionRef _Nullable WMMCreateVendAFCConnection( + WMMMobileDeviceFunctions *functions, + AMDeviceRef device, + NSString *bundleIdentifier, + AMDServiceConnectionRef _Nullable * _Nullable backingServiceConnection, + NSError **error +) { + if (backingServiceConnection != NULL) { + *backingServiceConnection = NULL; + } + + NSLog(@"[HouseArrest] Trying AMDeviceCreateHouseArrestService for %@", bundleIdentifier); + AFCConnectionRef directConnection = NULL; + int directStatus = functions->AMDeviceCreateHouseArrestService( + device, + (__bridge CFStringRef)bundleIdentifier, + NULL, + &directConnection + ); + NSLog(@"[HouseArrest] AMDeviceCreateHouseArrestService returned %d connection=%p", directStatus, directConnection); + if (directStatus == 0 && directConnection != NULL) { + return directConnection; + } + + NSArray *commands = @[ @"VendDocuments", @"VendContainer" ]; + NSMutableArray *failures = [NSMutableArray array]; + [failures addObject:[NSString stringWithFormat:@"AMDeviceCreateHouseArrestService returned %d", directStatus]]; + + for (NSString *command in commands) { + NSLog(@"[HouseArrest] Starting %@ for %@", command, bundleIdentifier); + AMDServiceConnectionRef serviceConnection = NULL; + int startStatus = functions->AMDeviceSecureStartService( + device, + CFSTR("com.apple.mobile.house_arrest"), + NULL, + &serviceConnection + ); + if (startStatus != 0 || serviceConnection == NULL) { + NSLog(@"[HouseArrest] %@ service start failed: %d", command, startStatus); + [failures addObject:[NSString stringWithFormat:@"%@ service start failed (%d)", command, startStatus]]; + continue; + } + + int socket = functions->AMDServiceConnectionGetSocket(serviceConnection); + void *secureContext = functions->AMDServiceConnectionGetSecureIOContext(serviceConnection); + NSLog(@"[HouseArrest] %@ service connection socket=%d secureContext=%p", command, socket, secureContext); + + NSDictionary *request = @{ + @"Command": command, + @"Identifier": bundleIdentifier + }; + + int sent = functions->AMDServiceConnectionSendMessage( + serviceConnection, + (__bridge CFPropertyListRef)request, + 100 + ); + NSLog(@"[HouseArrest] %@ send returned %d", command, sent); + if (sent != 0) { + [failures addObject:[NSString stringWithFormat:@"%@ request failed to send (%d)", command, sent]]; + functions->AMDServiceConnectionInvalidate(serviceConnection); + continue; + } + + CFPropertyListRef response = NULL; + int received = functions->AMDServiceConnectionReceiveMessage( + serviceConnection, + &response, + 0 + ); + NSLog(@"[HouseArrest] %@ receive returned %d", command, received); + if (received != 0 || response == NULL) { + [failures addObject:[NSString stringWithFormat:@"%@ response could not be read (%d)", command, received]]; + functions->AMDServiceConnectionInvalidate(serviceConnection); + continue; + } + + NSDictionary *responseDictionary = CFBridgingRelease(response); + NSLog(@"[HouseArrest] %@ response: %@", command, responseDictionary); + NSString *status = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Status"] : nil; + if ([status isKindOfClass:[NSString class]] && [status isEqualToString:@"Complete"]) { + AFCConnectionRef afcConnection = WMMCreateAFCConnectionFromServiceConnection(functions, serviceConnection); + if (afcConnection != NULL) { + if (backingServiceConnection != NULL) { + *backingServiceConnection = serviceConnection; + } + NSLog(@"[HouseArrest] %@ completed and AFC initialized", command); + return afcConnection; + } + + functions->AMDServiceConnectionInvalidate(serviceConnection); + NSLog(@"[HouseArrest] %@ completed but AFC initialization failed", command); + [failures addObject:[NSString stringWithFormat:@"%@ succeeded but AFC initialization failed", command]]; + break; + } + + NSString *serviceError = [responseDictionary isKindOfClass:[NSDictionary class]] ? responseDictionary[@"Error"] : nil; + if ([serviceError isKindOfClass:[NSString class]] && serviceError.length > 0) { + NSLog(@"[HouseArrest] %@ rejected with error: %@", command, serviceError); + [failures addObject:[NSString stringWithFormat:@"%@ was rejected: %@", command, serviceError]]; + } else { + NSLog(@"[HouseArrest] %@ did not complete", command); + [failures addObject:[NSString stringWithFormat:@"%@ did not complete", command]]; + } + functions->AMDServiceConnectionInvalidate(serviceConnection); + } + + if (error != NULL) { + NSString *failureSummary = failures.count > 0 + ? [failures componentsJoinedByString:@"; "] + : @"House Arrest vend request failed."; + *error = WMMMakeError(11, failureSummary); + } + return NULL; +} + +static int WMMReadAFCDirectory( + WMMMobileDeviceFunctions *functions, + AFCConnectionRef afcConnection, + NSString *path, + NSMutableArray **entriesOut +) { + NSString *effectivePath = path; + if (effectivePath.length == 0) { + effectivePath = @"."; + } + + AFCDirectoryRef directory = NULL; + const int openDirectoryStatus = functions->AFCDirectoryOpen( + afcConnection, + effectivePath.fileSystemRepresentation, + &directory + ); + if (openDirectoryStatus != 0 || directory == NULL) { + return openDirectoryStatus != 0 ? openDirectoryStatus : -1; + } + + NSMutableArray *entries = [NSMutableArray array]; + int result = 0; + while (true) { + char *entry = NULL; + const int readStatus = functions->AFCDirectoryRead(afcConnection, directory, &entry); + if (readStatus != 0) { + result = readStatus; + break; + } + + if (entry == NULL) { + break; + } + + NSString *entryName = [NSString stringWithUTF8String:entry]; + if (entryName.length > 0) { + [entries addObject:entryName]; + } + } + + functions->AFCDirectoryClose(afcConnection, directory); + if (result == 0 && entriesOut != NULL) { + *entriesOut = entries; + } + return result; +} + +static BOOL WMMCopyAFCFileToLocalURL( + WMMMobileDeviceFunctions *functions, + AFCConnectionRef afcConnection, + NSString *remotePath, + NSURL *localFileURL, + 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 NO; + } + + NSFileManager *fileManager = [NSFileManager defaultManager]; + [fileManager createFileAtPath:localFileURL.path contents:nil attributes:nil]; + + NSFileHandle *handle = [NSFileHandle fileHandleForWritingToURL:localFileURL error:error]; + if (handle == nil) { + functions->AFCFileRefClose(afcConnection, fileDescriptor); + return NO; + } + + BOOL success = YES; + 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]); + } + success = NO; + break; + } + + if (bytesToRead == 0) { + break; + } + + NSData *chunk = [NSData dataWithBytes:buffer.bytes length:bytesToRead]; + if (![handle writeData:chunk error:error]) { + success = NO; + break; + } + } + + [handle closeFile]; + functions->AFCFileRefClose(afcConnection, fileDescriptor); + return success; +} + +static BOOL WMMCopyAFCTreeToLocalURL( + WMMMobileDeviceFunctions *functions, + AFCConnectionRef afcConnection, + NSString *remotePath, + NSURL *localURL, + NSError **error +) { + NSMutableArray *entries = nil; + const int directoryStatus = WMMReadAFCDirectory(functions, afcConnection, remotePath, &entries); + if (directoryStatus == 0) { + NSFileManager *fileManager = [NSFileManager defaultManager]; + if (![fileManager createDirectoryAtURL:localURL withIntermediateDirectories:YES attributes:nil error:error]) { + return NO; + } + + for (NSString *entry in entries) { + if ([entry isEqualToString:@"."] || [entry isEqualToString:@".."]) { + continue; + } + + NSString *childRemotePath = remotePath; + if ([childRemotePath hasSuffix:@"/"]) { + childRemotePath = [childRemotePath stringByAppendingString:entry]; + } else { + childRemotePath = [childRemotePath stringByAppendingPathComponent:entry]; + } + NSURL *childLocalURL = [localURL URLByAppendingPathComponent:entry isDirectory:YES]; + if (!WMMCopyAFCTreeToLocalURL(functions, afcConnection, childRemotePath, childLocalURL, error)) { + return NO; + } + } + + return YES; + } + + return WMMCopyAFCFileToLocalURL(functions, afcConnection, remotePath, localURL, error); +} + +NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceSummary(NSError **error) { + WMMMobileDeviceFunctions functions; + if (!WMMLoadFunctions(&functions, error)) { + return nil; + } + + AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); + if (device == NULL) { + return nil; + } + + if (!WMMConnectAndValidateDevice(&functions, device, NO, error)) { + functions.AMDeviceRelease(device); + return nil; + } + + NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; + NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; + NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; + NSString *deviceIdentifier = + WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: + WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: + @""; + + NSString *trustState = @"trusted"; + + WMMDisconnectDevice(&functions, device, NO); + + functions.AMDeviceRelease(device); + + return @{ + @"deviceName": deviceName, + @"deviceIdentifier": deviceIdentifier, + @"productType": productType, + @"productVersion": productVersion, + @"trustState": trustState + }; +} + +NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceAppDirectoryListing( + NSString *bundleIdentifier, + NSString *relativePath, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(4, @"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; + } + + NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; + NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; + NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; + NSString *deviceIdentifier = + WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: + WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: + @""; + + 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 = relativePath.length == 0 ? @"/" : relativePath; + if (![normalizedPath hasPrefix:@"/"]) { + normalizedPath = [@"/" stringByAppendingString:normalizedPath]; + } + + NSMutableArray *entries = nil; + const int directoryStatus = WMMReadAFCDirectory(&functions, afcConnection, normalizedPath, &entries); + if (directoryStatus != 0) { + NSMutableArray *rootEntries = nil; + const int rootStatus = WMMReadAFCDirectory(&functions, afcConnection, @"/", &rootEntries); + + functions.AFCConnectionClose(afcConnection); + if (backingServiceConnection != NULL) { + functions.AMDServiceConnectionInvalidate(backingServiceConnection); + } + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + if (error != NULL) { + NSString *message = [NSString stringWithFormat:@"AFC directory read failed for %@ (%d).", normalizedPath, directoryStatus]; + if (rootStatus == 0 && rootEntries.count > 0) { + NSString *rootSummary = [rootEntries componentsJoinedByString:@", "]; + message = [message stringByAppendingFormat:@" Vend root contains: %@.", rootSummary]; + } else if (rootStatus != 0) { + message = [message stringByAppendingFormat:@" Vend root listing also failed (%d).", rootStatus]; + } + *error = WMMMakeError(directoryStatus, message); + } + return nil; + } + functions.AFCConnectionClose(afcConnection); + if (backingServiceConnection != NULL) { + functions.AMDServiceConnectionInvalidate(backingServiceConnection); + } + WMMDisconnectDevice(&functions, device, YES); + + functions.AMDeviceRelease(device); + + return @{ + @"bundleIdentifier": bundleIdentifier, + @"path": normalizedPath, + @"deviceName": deviceName, + @"deviceIdentifier": deviceIdentifier, + @"productType": productType, + @"productVersion": productVersion, + @"entries": entries + }; +} + +NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceAppPathProbeResults( + NSString *bundleIdentifier, + NSArray *paths, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(4, @"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; + } + + NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; + NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; + NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; + NSString *deviceIdentifier = + WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: + WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: + @""; + + AMDServiceConnectionRef backingServiceConnection = NULL; + AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( + &functions, + device, + bundleIdentifier, + &backingServiceConnection, + error + ); + if (afcConnection == NULL) { + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + return nil; + } + + NSMutableArray *> *results = [NSMutableArray array]; + for (NSString *candidate in paths) { + if (![candidate isKindOfClass:[NSString class]]) { + continue; + } + + NSMutableArray *entries = nil; + int status = WMMReadAFCDirectory(&functions, afcConnection, candidate, &entries); + + NSMutableDictionary *result = [@{ + @"path": candidate, + @"status": @(status), + @"success": @(status == 0) + } mutableCopy]; + if (status == 0 && entries != nil) { + result[@"entries"] = entries; + } + + [results addObject:result]; + } + + functions.AFCConnectionClose(afcConnection); + if (backingServiceConnection != NULL) { + functions.AMDServiceConnectionInvalidate(backingServiceConnection); + } + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + + return @{ + @"bundleIdentifier": bundleIdentifier, + @"deviceName": deviceName, + @"deviceIdentifier": deviceIdentifier, + @"productType": productType, + @"productVersion": productVersion, + @"results": results + }; +} + +NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceApplicationList(NSError **error) { + 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; + } + + NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; + NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; + NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; + NSString *deviceIdentifier = + WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: + WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: + @""; + + CFDictionaryRef appDictionary = NULL; + const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary); + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + + if (lookupStatus != 0 || appDictionary == NULL) { + if (error != NULL) { + *error = WMMMakeError(lookupStatus, [NSString stringWithFormat:@"AMDeviceLookupApplications failed (%d).", lookupStatus]); + } + return nil; + } + + NSMutableArray *> *applications = [NSMutableArray array]; + NSDictionary *bridgedApps = CFBridgingRelease(appDictionary); + [bridgedApps enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + (void)stop; + if (![key isKindOfClass:[NSString class]]) { + return; + } + + NSString *bundleIdentifier = (NSString *)key; + NSString *displayName = bundleIdentifier; + + if ([obj isKindOfClass:[NSDictionary class]]) { + NSDictionary *appInfo = (NSDictionary *)obj; + NSString *candidateName = appInfo[@"CFBundleDisplayName"]; + if (![candidateName isKindOfClass:[NSString class]] || candidateName.length == 0) { + candidateName = appInfo[@"CFBundleName"]; + } + if (![candidateName isKindOfClass:[NSString class]] || candidateName.length == 0) { + candidateName = appInfo[@"Path"]; + } + if ([candidateName isKindOfClass:[NSString class]] && candidateName.length > 0) { + displayName = candidateName; + } + } + + NSNumber *uiFileSharingEnabled = @NO; + NSNumber *supportsOpeningDocumentsInPlace = @NO; + if ([obj isKindOfClass:[NSDictionary class]]) { + NSDictionary *appInfo = (NSDictionary *)obj; + id fileSharingValue = appInfo[@"UIFileSharingEnabled"]; + if ([fileSharingValue isKindOfClass:[NSNumber class]]) { + uiFileSharingEnabled = fileSharingValue; + } + + id openingInPlaceValue = appInfo[@"LSSupportsOpeningDocumentsInPlace"]; + if ([openingInPlaceValue isKindOfClass:[NSNumber class]]) { + supportsOpeningDocumentsInPlace = openingInPlaceValue; + } + } + + [applications addObject:@{ + @"bundleIdentifier": bundleIdentifier, + @"displayName": displayName, + @"uiFileSharingEnabled": uiFileSharingEnabled, + @"supportsOpeningDocumentsInPlace": supportsOpeningDocumentsInPlace + }]; + }]; + + [applications sortUsingComparator:^NSComparisonResult(NSDictionary *lhs, NSDictionary *rhs) { + return [lhs[@"displayName"] localizedStandardCompare:rhs[@"displayName"]]; + }]; + + return @{ + @"deviceName": deviceName, + @"deviceIdentifier": deviceIdentifier, + @"productType": productType, + @"productVersion": productVersion, + @"applications": applications + }; +} + +NSDictionary * _Nullable +WMMCopyFirstConnectedDeviceApplicationDetails( + NSString *bundleIdentifier, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(12, @"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; + } + + NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; + NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; + NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; + NSString *deviceIdentifier = + WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: + WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: + @""; + + CFDictionaryRef appDictionary = NULL; + const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary); + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + + if (lookupStatus != 0 || appDictionary == NULL) { + if (error != NULL) { + *error = WMMMakeError(lookupStatus, [NSString stringWithFormat:@"AMDeviceLookupApplications failed (%d).", lookupStatus]); + } + return nil; + } + + NSDictionary *bridgedApps = CFBridgingRelease(appDictionary); + NSDictionary *appInfo = [bridgedApps objectForKey:bundleIdentifier]; + if (![appInfo isKindOfClass:[NSDictionary class]]) { + if (error != NULL) { + *error = WMMMakeError(13, [NSString stringWithFormat:@"No app record was found for %@.", bundleIdentifier]); + } + return nil; + } + + NSMutableDictionary *details = [NSMutableDictionary dictionary]; + [appInfo enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + (void)stop; + if (![key isKindOfClass:[NSString class]]) { + return; + } + + NSString *detailKey = (NSString *)key; + if ([obj isKindOfClass:[NSString class]]) { + details[detailKey] = (NSString *)obj; + return; + } + if ([obj isKindOfClass:[NSNumber class]]) { + details[detailKey] = [(NSNumber *)obj stringValue]; + return; + } + if ([obj isKindOfClass:[NSArray class]] || [obj isKindOfClass:[NSDictionary class]]) { + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil]; + if (jsonData != nil) { + details[detailKey] = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding] ?: @""; + } + } + }]; + + return @{ + @"deviceName": deviceName, + @"deviceIdentifier": deviceIdentifier, + @"productType": productType, + @"productVersion": productVersion, + @"bundleIdentifier": bundleIdentifier, + @"details": details + }; +} + +BOOL +WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( + NSString *bundleIdentifier, + NSString *relativePath, + NSURL *destinationDirectoryURL, + NSError **error +) { + if (bundleIdentifier.length == 0) { + if (error != NULL) { + *error = WMMMakeError(14, @"A bundle identifier is required."); + } + return NO; + } + + if (destinationDirectoryURL == nil || !destinationDirectoryURL.isFileURL) { + if (error != NULL) { + *error = WMMMakeError(15, @"A local destination directory is required."); + } + return NO; + } + + WMMMobileDeviceFunctions functions; + if (!WMMLoadFunctions(&functions, error)) { + return NO; + } + + AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); + if (device == NULL) { + return NO; + } + + if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) { + functions.AMDeviceRelease(device); + return NO; + } + + AMDServiceConnectionRef backingServiceConnection = NULL; + AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( + &functions, + device, + bundleIdentifier, + &backingServiceConnection, + error + ); + if (afcConnection == NULL) { + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + return NO; + } + + NSString *normalizedPath = relativePath.length == 0 ? @"/" : relativePath; + if (![normalizedPath hasPrefix:@"/"]) { + normalizedPath = [@"/" stringByAppendingString:normalizedPath]; + } + + NSFileManager *fileManager = [NSFileManager defaultManager]; + [fileManager removeItemAtURL:destinationDirectoryURL error:nil]; + + BOOL success = WMMCopyAFCTreeToLocalURL( + &functions, + afcConnection, + normalizedPath, + destinationDirectoryURL, + error + ); + + functions.AFCConnectionClose(afcConnection); + if (backingServiceConnection != NULL) { + functions.AMDServiceConnectionInvalidate(backingServiceConnection); + } + WMMDisconnectDevice(&functions, device, YES); + functions.AMDeviceRelease(device); + + if (!success) { + [fileManager removeItemAtURL:destinationDirectoryURL error:nil]; + } + + return success; +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift new file mode 100644 index 0000000..cded998 --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -0,0 +1,109 @@ +// +// AppleMobileDeviceSourceAccess.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { + private let mirrorRootURL: URL + + nonisolated init( + mirrorRootURL: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("WorldManagerConnectedDevices", isDirectory: true) + ) { + self.mirrorRootURL = mirrorRootURL + } + + nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] { + let device = try await AppleMobileDeviceAccess.firstConnectedDevice() + return [ + ConnectedDevice( + udid: device.deviceIdentifier, + name: device.deviceName, + productType: device.productType.isEmpty ? nil : device.productType, + osVersion: device.productVersion.isEmpty ? nil : device.productVersion, + connection: .usb, + trustState: device.trustState + ) + ] + } + + nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] { + let applications = try await AppleMobileDeviceAccess.listApplications() + + return applications + .filter { $0.fileSharingEnabled } + .map { application in + DeviceAppContainer( + deviceUDID: device.udid, + appID: application.bundleIdentifier, + appName: application.displayName, + accessMode: .documents, + minecraftFolderRelativePath: application.bundleIdentifier == "com.mojang.minecraftpe" + ? "Documents/games/com.mojang" + : nil + ) + } + .sorted { lhs, rhs in + if lhs.appID == "com.mojang.minecraftpe" { + return true + } + if rhs.appID == "com.mojang.minecraftpe" { + return false + } + return lhs.appName.localizedStandardCompare(rhs.appName) == .orderedAscending + } + } + + nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot { + guard case .connectedDevice(_, let container) = source.origin else { + throw SourceAccessError.accessFailed( + reason: "The selected source is not backed by a connected mobile device." + ) + } + + let requestedSubpath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !requestedSubpath.isEmpty else { + throw SourceAccessError.accessFailed( + reason: "A device-backed source requires a vend-relative Minecraft path." + ) + } + + let fileManager = FileManager.default + let mirrorURL = mirrorRootURL + .appendingPathComponent(container.deviceUDID, isDirectory: true) + .appendingPathComponent(container.appID.replacingOccurrences(of: ".", with: "_"), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + do { + try fileManager.createDirectory(at: mirrorURL, withIntermediateDirectories: true) + try await AppleMobileDeviceAccess.mirrorSubtree( + bundleIdentifier: container.appID, + relativePath: requestedSubpath, + destinationDirectoryURL: mirrorURL + ) + } catch { + try? fileManager.removeItem(at: mirrorURL) + throw SourceAccessError.accessFailed(reason: error.localizedDescription) + } + + 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 { + return + } + + try? FileManager.default.removeItem(at: mountPointURL) + } +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift new file mode 100644 index 0000000..93c0905 --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift @@ -0,0 +1,44 @@ +// +// ConnectedDeviceSourceFactory.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +struct ConnectedDeviceSourceFactory: Sendable { + nonisolated init() {} + + nonisolated func makeSource( + device: ConnectedDevice, + container: DeviceAppContainer + ) -> MinecraftSource { + let sourceID = makeSourceIdentifier(device: device, container: container) + let placeholderFolderURL = URL(fileURLWithPath: "/Volumes/\(sourceID.lastPathComponent)", isDirectory: true) + + var source = MinecraftSource( + sourceID: sourceID, + folderURL: placeholderFolderURL, + origin: .connectedDevice(device: device, container: container) + ) + source.displayName = displayName(for: device, container: container) + return source + } + + nonisolated func displayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { + "\(device.name) • \(container.appName)" + } + + nonisolated func makeSourceIdentifier(device: ConnectedDevice, container: DeviceAppContainer) -> URL { + var components = URLComponents() + components.scheme = "wmminecraft-device" + components.host = container.deviceUDID + components.path = "/\(container.appID)" + components.queryItems = [ + URLQueryItem(name: "mode", value: container.accessMode.rawValue) + ] + + return components.url ?? URL(string: "wmminecraft-device://\(container.deviceUDID)/\(container.appID)")! + } +} diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift new file mode 100644 index 0000000..6a16189 --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift @@ -0,0 +1,293 @@ +// +// ConnectedDeviceSourcePickerView.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import SwiftUI + +struct ConnectedDeviceSourcePickerView: View { + let deviceDiscoveryService: ConnectedDeviceSourceAccessMethod + let sourceFactory: ConnectedDeviceSourceFactory + let onAddSource: (MinecraftSource) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var devices: [ConnectedDevice] = [] + @State private var containers: [DeviceAppContainer] = [] + @State private var selectedDeviceID: ConnectedDevice.ID? + @State private var selectedContainerID: DeviceAppContainer.ID? + @State private var preferredMinecraftSubpath = "Documents/games/com.mojang" + @State private var isLoadingDevices = false + @State private var isLoadingContainers = false + @State private var availabilityMessage: String? + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + + if let availabilityMessage { + Text(availabilityMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 18) + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .padding(.horizontal, 18) + } + + HStack(alignment: .top, spacing: 18) { + deviceColumn + containerColumn + } + .frame(maxHeight: .infinity, alignment: .top) + + VStack(alignment: .leading, spacing: 8) { + Text("Minecraft Subpath") + .font(.subheadline.weight(.semibold)) + TextField("Documents/games/com.mojang", text: $preferredMinecraftSubpath) + .textFieldStyle(.roundedBorder) + Text("Used after the app container is mounted. The current proven path is `Documents/games/com.mojang`.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + + footer + } + .frame(minWidth: 760, minHeight: 420) + .padding(.vertical, 18) + .task { + await loadDevices() + } + .onChange(of: selectedDeviceID) { _, _ in + Task { + await loadContainersForSelectedDevice() + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Add Connected Device Source") + .font(.title2.weight(.semibold)) + Text("Choose a connected device source and scan its Minecraft documents just like a local folder.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + } + + private var deviceColumn: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Devices") + .font(.headline) + + Spacer() + + Button("Refresh") { + Task { + await loadDevices() + } + } + .disabled(isLoadingDevices) + } + + Group { + if isLoadingDevices { + loadingState("Searching for connected devices...") + } else if devices.isEmpty { + emptyState("No devices found") + } else { + List(devices, selection: $selectedDeviceID) { device in + VStack(alignment: .leading, spacing: 4) { + Text(device.name) + .font(.subheadline.weight(.semibold)) + Text(deviceSubtitle(device)) + .font(.footnote) + .foregroundStyle(.secondary) + } + .tag(device.id) + } + .listStyle(.inset) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 18) + } + + private var containerColumn: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Accessible Apps") + .font(.headline) + + Group { + if selectedDevice == nil { + emptyState("Select a device to list file-sharing apps") + } else if isLoadingContainers { + loadingState("Listing accessible app containers...") + } else if containers.isEmpty { + emptyState("No accessible apps found for this device") + } else { + List(containers, selection: $selectedContainerID) { container in + VStack(alignment: .leading, spacing: 4) { + Text(container.appName) + .font(.subheadline.weight(.semibold)) + Text(container.appID) + .font(.footnote) + .foregroundStyle(.secondary) + } + .tag(container.id) + } + .listStyle(.inset) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 18) + } + + private var footer: some View { + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Add Device Source") { + guard let selectedDevice, let selectedContainer else { + return + } + + var adjustedContainer = selectedContainer + let trimmedSubpath = preferredMinecraftSubpath.trimmingCharacters(in: .whitespacesAndNewlines) + adjustedContainer.minecraftFolderRelativePath = trimmedSubpath.isEmpty ? nil : trimmedSubpath + let source = sourceFactory.makeSource(device: selectedDevice, container: adjustedContainer) + onAddSource(source) + } + .buttonStyle(.borderedProminent) + .disabled(selectedDevice == nil || selectedContainer == nil) + } + .padding(.horizontal, 18) + } + + private var selectedDevice: ConnectedDevice? { + devices.first(where: { $0.id == selectedDeviceID }) + } + + private var selectedContainer: DeviceAppContainer? { + containers.first(where: { $0.id == selectedContainerID }) + } + + private func loadDevices() async { + isLoadingDevices = true + availabilityMessage = nil + errorMessage = nil + + do { + let devices = try await deviceDiscoveryService.listConnectedDevices() + await MainActor.run { + self.devices = devices + self.selectedDeviceID = devices.first?.id + if devices.isEmpty { + self.containers = [] + self.selectedContainerID = nil + } + self.isLoadingDevices = false + } + } catch { + await MainActor.run { + self.devices = [] + self.containers = [] + self.selectedDeviceID = nil + self.selectedContainerID = nil + self.errorMessage = error.localizedDescription + self.isLoadingDevices = false + self.isLoadingContainers = false + } + } + } + + private func loadContainersForSelectedDevice() async { + guard let selectedDevice else { + await MainActor.run { + containers = [] + selectedContainerID = nil + isLoadingContainers = false + } + return + } + + isLoadingContainers = true + availabilityMessage = nil + errorMessage = nil + + do { + let containers = try await deviceDiscoveryService.listAccessibleContainers(for: selectedDevice) + await MainActor.run { + self.containers = containers + self.selectedContainerID = preferredContainerID(in: containers) + self.isLoadingContainers = false + } + } catch { + await MainActor.run { + self.containers = [] + self.selectedContainerID = nil + self.errorMessage = error.localizedDescription + self.isLoadingContainers = false + } + } + } + + private func preferredContainerID(in containers: [DeviceAppContainer]) -> DeviceAppContainer.ID? { + if let minecraftContainer = containers.first(where: { $0.appID == "com.mojang.minecraftpe" }) { + return minecraftContainer.id + } + + return containers.first?.id + } + + private func deviceSubtitle(_ device: ConnectedDevice) -> String { + [ + device.productType, + device.osVersion.map { "iOS/iPadOS \($0)" }, + device.connection == .network ? "Network" : "USB" + ] + .compactMap { $0 } + .joined(separator: " • ") + } + + private func loadingState(_ title: String) -> some View { + VStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(title) + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func emptyState(_ title: String) -> some View { + VStack(spacing: 10) { + Image(systemName: "externaldrive.badge.questionmark") + .font(.title2) + .foregroundStyle(.secondary) + Text(title) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift new file mode 100644 index 0000000..9eceba4 --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -0,0 +1,74 @@ +// +// SourceAccessCoordinator.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +protocol SourceAccessMethod: Sendable { + nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot + nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async +} + +extension SourceAccessMethod { + nonisolated func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async { + _ = preparedScanRoot + } +} + +protocol ConnectedDeviceSourceAccessMethod: SourceAccessMethod { + nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] + nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] +} + +struct SourceAccessCoordinator: SourceAccessMethod { + private let localFolderAccess: SourceAccessMethod + private let connectedDeviceAccess: ConnectedDeviceSourceAccessMethod + + nonisolated init( + localFolderAccess: SourceAccessMethod = LocalFolderSourceAccess(), + connectedDeviceAccess: ConnectedDeviceSourceAccessMethod + ) { + self.localFolderAccess = localFolderAccess + self.connectedDeviceAccess = 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 func releaseScanRoot(_ preparedScanRoot: PreparedScanRoot) async { + await localFolderAccess.releaseScanRoot(preparedScanRoot) + await connectedDeviceAccess.releaseScanRoot(preparedScanRoot) + } +} + +enum SourceAccessError: LocalizedError, Sendable { + case deviceUnavailable + case deviceNotTrusted + case appNotAccessible(appID: String) + case minecraftFolderMissing(appID: String) + case accessFailed(reason: String) + + var errorDescription: String? { + switch self { + case .deviceUnavailable: + return "The selected device is no longer available." + case .deviceNotTrusted: + return "The device must be unlocked and trusted before its files can be accessed." + case .appNotAccessible(let appID): + return "The app container for \(appID) is not accessible on this device." + case .minecraftFolderMissing(let appID): + return "Minecraft resources were not found in the accessible container for \(appID)." + case .accessFailed(let reason): + return reason + } + } +} diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift new file mode 100644 index 0000000..7147f9a --- /dev/null +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -0,0 +1,46 @@ +// +// LocalFolderSourceAccess.swift +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +import Foundation + +struct LocalFolderSourceAccess: SourceAccessMethod { + nonisolated init() {} + + nonisolated func prepareScanRoot(for source: MinecraftSource) async throws -> PreparedScanRoot { + guard case .localFolder(let bookmarkData) = source.origin else { + throw SourceAccessError.accessFailed( + reason: "No local-folder access method is configured for this source type." + ) + } + + let resolvedURL: URL + if let bookmarkData { + var isStale = false + guard let bookmarkURL = try? URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + throw SourceAccessError.accessFailed( + reason: "The saved folder bookmark could not be resolved." + ) + } + + resolvedURL = bookmarkURL.standardizedFileURL + } else { + resolvedURL = source.folderURL + } + + return PreparedScanRoot( + sourceID: source.id, + rootURL: resolvedURL, + mountPointURL: nil, + cleanupBehavior: .none + ) + } +} diff --git a/World Manager for Minecraft/WorldManagerBridgingHeader.h b/World Manager for Minecraft/WorldManagerBridgingHeader.h new file mode 100644 index 0000000..1a73cdb --- /dev/null +++ b/World Manager for Minecraft/WorldManagerBridgingHeader.h @@ -0,0 +1,8 @@ +// +// WorldManagerBridgingHeader.h +// World Manager for Minecraft +// +// Created by OpenAI on 2026-05-26. +// + +#import "SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h" diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index 460a824..48ce466 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -314,6 +314,67 @@ struct World_Manager_for_MinecraftTests { #expect(restored.first?.lastScanDate == source.lastScanDate) } + @Test func connectedDeviceSourceFactoryCreatesStableSyntheticIdentifier() async throws { + let device = ConnectedDevice( + udid: "00008110-001234560E90001E", + name: "John's iPhone", + productType: "iPhone16,2", + osVersion: "18.0", + connection: .usb, + trustState: .trusted + ) + let container = DeviceAppContainer( + deviceUDID: device.udid, + appID: "com.mojang.minecraftpe", + appName: "Minecraft", + accessMode: .documents, + minecraftFolderRelativePath: "games/com.mojang" + ) + + let source = ConnectedDeviceSourceFactory().makeSource(device: device, container: container) + + #expect(source.origin.kind == .connectedDevice) + #expect(source.id.scheme == "wmminecraft-device") + #expect(source.id.host == device.udid) + #expect(source.displayName == "John's iPhone • Minecraft") + } + + @Test func ifuseDeviceServicesParsesListAppsOutputAcrossCommonFormats() async throws { + let output = """ + com.mojang.minecraftpe - Minecraft + VLC (org.videolan.vlc-ios) + Documents\tcom.readdle.ReaddleDocs + com.apple.Pages + """ + + let containers = IFuseDeviceServices.parseAppContainers( + from: output, + deviceUDID: "device-1" + ) + + #expect(containers.count == 4) + #expect(containers.contains { $0.appID == "com.mojang.minecraftpe" && $0.appName == "Minecraft" }) + #expect(containers.contains { $0.appID == "org.videolan.vlc-ios" && $0.appName == "VLC" }) + #expect(containers.contains { $0.appID == "com.readdle.ReaddleDocs" && $0.appName == "Documents" }) + #expect(containers.contains { $0.appID == "com.apple.Pages" && $0.appName == "com.apple.Pages" }) + } + + @Test func ifuseDeviceServicesParsesIdeviceInfoKeyValueOutput() async throws { + let output = """ + DeviceName: John's iPad + ProductType: iPad14,5 + ProductVersion: 18.1 + ConnectionType: USB + """ + + let values = IFuseDeviceServices.parseKeyValueOutput(output) + + #expect(values["DeviceName"] == "John's iPad") + #expect(values["ProductType"] == "iPad14,5") + #expect(values["ProductVersion"] == "18.1") + #expect(values["ConnectionType"] == "USB") + } + } private enum TestNBTTagType: UInt8 { diff --git a/docs/ios-device-access.md b/docs/ios-device-access.md new file mode 100644 index 0000000..1aec0e4 --- /dev/null +++ b/docs/ios-device-access.md @@ -0,0 +1,177 @@ +# iOS Device Access Notes + +## Summary + +This project can now read Minecraft Bedrock content directly from a connected iPhone or iPad on macOS using `MobileDevice.framework` and House Arrest. + +The app does **not** scan the device live through custom file APIs. Instead, it: + +1. Detects a connected trusted device with `MobileDevice.framework`. +2. Opens House Arrest for `com.mojang.minecraftpe`. +3. Uses `VendDocuments`. +4. Mirrors the proven Minecraft subtree into a temporary local folder. +5. Hands that local folder to the existing `WorldScanner`. + +That keeps the rest of the app filesystem-based. + +## Proven Findings + +### Correct bundle ID + +Minecraft on the tested device is: + +- `com.mojang.minecraftpe` + +### App metadata + +The app record reported: + +- `UIFileSharingEnabled = 1` +- `LSSupportsOpeningDocumentsInPlace = 1` + +So Minecraft does expose a vendable Documents surface. + +### Correct vend root + +House Arrest `VendDocuments` succeeds, but the Minecraft content is **not** rooted at: + +- `games/com.mojang` + +The proven path is: + +- `Documents/games/com.mojang` + +### Proven subfolders + +These paths were verified through AFC on the real device: + +- `Documents/games/com.mojang/minecraftWorlds` +- `Documents/games/com.mojang/resource_packs` +- `Documents/games/com.mojang/behavior_packs` +- `Documents/games/com.mojang/world_templates` + +## Important API Findings + +### `AMDeviceCreateHouseArrestService` + +The direct helper path still returned a device-side error on the tested device: + +- `InstallationLookupFailed` +- `e80000b7` + +So the app currently relies on the explicit vend flow instead: + +1. `AMDeviceSecureStartService("com.apple.mobile.house_arrest")` +2. `AMDServiceConnectionSendMessage` with `VendDocuments` +3. `AMDServiceConnectionReceiveMessage` +4. Create AFC from the returned service connection + +### `VendDocuments` vs `VendContainer` + +`VendDocuments` is the working path for Minecraft on the tested device. + +### AFC path behavior + +The AFC root is not a normal `/` root for this vend. Examples: + +- `"/"` returned AFC status `0xA` +- `"/games/com.mojang"` returned AFC status `0x8` +- `"Documents/games/com.mojang"` worked + +So code should use the proven vend-relative path instead of assuming a container root layout. + +## Current Project Shape + +## Source Access Architecture + +Source intake is now organized around access methods rather than one-off services. + +- `SourceAccess/Core` + - shared contracts and the `SourceAccessCoordinator` +- `SourceAccess/LocalFolder` + - the local disk access method +- `SourceAccess/ConnectedDevice` + - connected-device source creation and picker UI +- `SourceAccess/ConnectedDevice/AppleMobileDevice` + - the built-in Apple mobile-device transport + +The key abstraction is `SourceAccessMethod`. + +Each access method is responsible for: + +1. turning a `MinecraftSource` into a local `PreparedScanRoot` +2. cleaning up that prepared root when scanning finishes + +That keeps the rest of the app indifferent to how a source is reached. + +### App path + +User flow: + +- `SourcesSidebarView` opens the connected-device sheet. +- `ConnectedDeviceSourcePickerView` lets the user select a device/app and a subpath. +- `AppleMobileDeviceSourceAccess` is the active connected-device access method. + +### Scan-root preparation + +`SourceAccessCoordinator` chooses between: + +- local folder sources +- connected device sources + +For connected devices: + +- the built-in `AppleMobileDeviceSourceAccess` +- future connected-device access methods can implement the same contracts + +### Mirror behavior + +The MobileDevice fallback: + +- mirrors the subtree into a temporary directory +- returns that directory as `PreparedScanRoot.rootURL` +- cleans it up with `CleanupBehavior.deleteTemporaryDirectory` + +## Relevant Files + +- `World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m` +- `World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h` +- `World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift` +- `World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift` +- `World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift` +- `World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift` +- `World Manager for Minecraft/Models/SourceOrigin.swift` +- `World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift` + +## Developer CLI + +A local probe harness was added for iteration: + +- `Scripts/run_mobile_device_probe.sh` +- `Tools/mobile_device_probe.m` + +Useful commands: + +```sh +Scripts/run_mobile_device_probe.sh summary +Scripts/run_mobile_device_probe.sh apps +Scripts/run_mobile_device_probe.sh details com.mojang.minecraftpe +Scripts/run_mobile_device_probe.sh probe-paths com.mojang.minecraftpe +Scripts/run_mobile_device_probe.sh mirror com.mojang.minecraftpe 'Documents/games/com.mojang' /tmp/wmm-minecraft-device-mirror +``` + +These commands generally need to run outside the agent sandbox to access the real connected device. + +## Known Cleanup Targets + +Not part of the device-access implementation itself: + +- `layoutSubtreeIfNeeded` warning: SwiftUI/AppKit layout issue +- `duplicate column name: bookmark_data`: persistence migration issue +- preview actor-isolation warnings in `PreviewFixtures.swift` + +## Recommendation + +Keep the CLI probe and this document. + +They provide a reproducible path for future device-access debugging without going back through GUI-based experimentation.