Refactor source access methods; add live device
This commit is contained in:
parent
b2858b8ff6
commit
aca5baa155
18
Scripts/run_mobile_device_probe.sh
Executable file
18
Scripts/run_mobile_device_probe.sh
Executable 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
174
Tools/mobile_device_probe.m
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)")!
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
World Manager for Minecraft/WorldManagerBridgingHeader.h
Normal file
8
World Manager for Minecraft/WorldManagerBridgingHeader.h
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
//
|
||||||
|
// WorldManagerBridgingHeader.h
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by OpenAI on 2026-05-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h"
|
||||||
@ -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
177
docs/ios-device-access.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user