Refactor source access methods; add live device

This commit is contained in:
John Burwell 2026-05-26 18:10:46 -05:00
parent b2858b8ff6
commit aca5baa155
21 changed files with 2434 additions and 108 deletions

View File

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

174
Tools/mobile_device_probe.m Normal file
View File

@ -0,0 +1,174 @@
#import <Foundation/Foundation.h>
#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 <bundle-id>\n");
fprintf(stderr, " mobile_device_probe list <bundle-id> <path>\n");
fprintf(stderr, " mobile_device_probe probe-paths <bundle-id> [path ...]\n");
fprintf(stderr, " mobile_device_probe mirror <bundle-id> <path> <destination>\n");
}
static NSArray<NSString *> *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:@"<json-error %@>", error.localizedDescription ?: @"unknown"];
}
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"<encoding-error>";
}
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<NSString *> *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;
}
}

View File

@ -395,7 +395,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -413,6 +413,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
@ -444,6 +445,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };

View File

@ -10,17 +10,33 @@ import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
@StateObject private var library = SourceLibrary() @StateObject private var library: SourceLibrary
@State private var selectedItemID: MinecraftContentItem.ID? @State private var selectedItemID: MinecraftContentItem.ID?
@State private var selectedSidebarSelection: SidebarSelection? @State private var selectedSidebarSelection: SidebarSelection?
@State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var searchText = "" @State private var searchText = ""
@State private var isDropTargeted = false @State private var isDropTargeted = false
@State private var isPerformingItemAction = false @State private var isPerformingItemAction = false
@State private var isShowingDeviceSourceSheet = false
@State private var sortMode: ItemSortMode = .name @State private var sortMode: ItemSortMode = .name
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
private let deviceSourceFactory: ConnectedDeviceSourceFactory
private let directoryPreviewLimit = 12 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 { var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
@ -28,6 +44,7 @@ struct ContentView: View {
selection: $selectedSidebarSelection, selection: $selectedSidebarSelection,
footerState: library.sidebarFooterState, footerState: library.sidebarFooterState,
addSourceAction: pickFolder, addSourceAction: pickFolder,
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
rescanSourceAction: { source in rescanSourceAction: { source in
selectedSidebarSelection = .allContent(sourceID: source.id) selectedSidebarSelection = .allContent(sourceID: source.id)
selectedItemID = nil selectedItemID = nil
@ -104,6 +121,18 @@ struct ContentView: View {
LaunchRestoreOverlayView() 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) .disabled(library.isRestoringPersistedSources)
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {

View File

@ -27,17 +27,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var indexedDetailCount: Int var indexedDetailCount: Int
var lastScanDate: Date? var lastScanDate: Date?
init( nonisolated init(
sourceID: URL? = nil,
folderURL: URL, folderURL: URL,
bookmarkData: Data? = nil, bookmarkData: Data? = nil,
origin: MinecraftSourceOrigin? = nil origin: MinecraftSourceOrigin? = nil
) { ) {
let normalizedURL = folderURL.standardizedFileURL let normalizedFolderURL = normalizedSourceURL(folderURL)
self.id = normalizedURL self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
self.folderURL = normalizedURL self.folderURL = normalizedFolderURL
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData) self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
self.bookmarkData = bookmarkData self.bookmarkData = bookmarkData
self.displayName = normalizedURL.lastPathComponent self.displayName = normalizedFolderURL.lastPathComponent
self.displayItems = [] self.displayItems = []
self.rawItems = [] self.rawItems = []
self.logicalPacks = [] 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 { private extension Array {
func uniqued<Key: Hashable>(by keyPath: KeyPath<Element, Key>) -> [Element] { func uniqued<Key: Hashable>(by keyPath: KeyPath<Element, Key>) -> [Element] {
var seen = Set<Key>() var seen = Set<Key>()

View File

@ -69,10 +69,12 @@ enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
struct PreparedScanRoot: Hashable, Sendable { struct PreparedScanRoot: Hashable, Sendable {
let sourceID: URL let sourceID: URL
let rootURL: URL let rootURL: URL
let mountPointURL: URL?
let cleanupBehavior: CleanupBehavior let cleanupBehavior: CleanupBehavior
enum CleanupBehavior: Hashable, Sendable { enum CleanupBehavior: Hashable, Sendable {
case none case none
case unmount case unmount
case deleteTemporaryDirectory
} }
} }

View File

@ -294,6 +294,7 @@ struct SidebarColumnPreviewContainer: View {
selection: $selection, selection: $selection,
footerState: PreviewFixtures.sidebarFooter, footerState: PreviewFixtures.sidebarFooter,
addSourceAction: {}, addSourceAction: {},
addDeviceSourceAction: {},
rescanSourceAction: { _ in }, rescanSourceAction: { _ in },
removeSourceAction: { _ in }, removeSourceAction: { _ in },
revealFooterURLAction: { _ in }, revealFooterURLAction: { _ in },

View File

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

View File

@ -42,14 +42,14 @@ final class SourceLibrary: ObservableObject {
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>? private var footerResetTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore private let persistenceStore: SourcePersistenceStore
private let scanRootPreparer: SourceScanRootPreparing private let sourceAccessMethod: SourceAccessMethod
init( init(
persistenceStore: SourcePersistenceStore = .shared, persistenceStore: SourcePersistenceStore = .shared,
scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer() sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess()
) { ) {
self.persistenceStore = persistenceStore self.persistenceStore = persistenceStore
self.scanRootPreparer = scanRootPreparer self.sourceAccessMethod = sourceAccessMethod
Task { [weak self] in Task { [weak self] in
await self?.restorePersistedSources() await self?.restorePersistedSources()
@ -70,11 +70,35 @@ final class SourceLibrary: ObservableObject {
return normalizedURL return normalizedURL
} }
sources.append(MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)) 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 } sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
persistSourceIfAvailable(withID: normalizedURL) }
startScan(for: normalizedURL)
return normalizedURL 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? { func source(withID sourceID: URL) -> MinecraftSource? {
@ -169,7 +193,7 @@ final class SourceLibrary: ObservableObject {
let preparedScanRoot: PreparedScanRoot let preparedScanRoot: PreparedScanRoot
do { do {
preparedScanRoot = try await scanRootPreparer.prepareScanRoot(for: source) preparedScanRoot = try await sourceAccessMethod.prepareScanRoot(for: source)
} catch { } catch {
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.scanError = error.localizedDescription source.scanError = error.localizedDescription
@ -944,11 +968,8 @@ final class SourceLibrary: ObservableObject {
} }
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) { private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
switch preparedScanRoot.cleanupBehavior { Task.detached(priority: .utility) { [sourceAccessMethod] in
case .none: await sourceAccessMethod.releaseScanRoot(preparedScanRoot)
return
case .unmount:
return
} }
} }

View File

@ -25,6 +25,7 @@ struct SourcesSidebarView: View {
@Binding var selection: SidebarSelection? @Binding var selection: SidebarSelection?
let footerState: SidebarFooterState let footerState: SidebarFooterState
let addSourceAction: () -> Void let addSourceAction: () -> Void
let addDeviceSourceAction: () -> Void
let rescanSourceAction: (MinecraftSource) -> Void let rescanSourceAction: (MinecraftSource) -> Void
let removeSourceAction: (MinecraftSource) -> Void let removeSourceAction: (MinecraftSource) -> Void
let revealFooterURLAction: (URL) -> Void let revealFooterURLAction: (URL) -> Void
@ -77,6 +78,13 @@ struct SourcesSidebarView: View {
} }
.help("Add Source Folder") .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) .animation(.easeInOut(duration: 0.2), value: footerState.style)
} }

View File

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

View File

@ -0,0 +1,48 @@
//
// AppleMobileDeviceBridge.h
// World Manager for Minecraft
//
// Created by OpenAI on 2026-05-26.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain;
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceSummary(NSError **error);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationList(NSError **error);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationDetails(
NSString *bundleIdentifier,
NSError **error
);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppDirectoryListing(
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppPathProbeResults(
NSString *bundleIdentifier,
NSArray<NSString *> *paths,
NSError **error
);
FOUNDATION_EXPORT BOOL
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
NSString *bundleIdentifier,
NSString *relativePath,
NSURL *destinationDirectoryURL,
NSError **error
);
NS_ASSUME_NONNULL_END

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
//
// WorldManagerBridgingHeader.h
// World Manager for Minecraft
//
// Created by OpenAI on 2026-05-26.
//
#import "SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h"

View File

@ -314,6 +314,67 @@ struct World_Manager_for_MinecraftTests {
#expect(restored.first?.lastScanDate == source.lastScanDate) #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 { private enum TestNBTTagType: UInt8 {

177
docs/ios-device-access.md Normal file
View File

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