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;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -413,6 +413,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
@ -444,6 +445,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "World Manager for Minecraft/WorldManagerBridgingHeader.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
|
||||
@ -10,17 +10,33 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var library = SourceLibrary()
|
||||
@StateObject private var library: SourceLibrary
|
||||
@State private var selectedItemID: MinecraftContentItem.ID?
|
||||
@State private var selectedSidebarSelection: SidebarSelection?
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@State private var searchText = ""
|
||||
@State private var isDropTargeted = false
|
||||
@State private var isPerformingItemAction = false
|
||||
@State private var isShowingDeviceSourceSheet = false
|
||||
@State private var sortMode: ItemSortMode = .name
|
||||
|
||||
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
|
||||
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
||||
private let directoryPreviewLimit = 12
|
||||
|
||||
init() {
|
||||
let connectedDeviceAccess = AppleMobileDeviceSourceAccess()
|
||||
self.connectedDeviceAccess = connectedDeviceAccess
|
||||
self.deviceSourceFactory = ConnectedDeviceSourceFactory()
|
||||
_library = StateObject(
|
||||
wrappedValue: SourceLibrary(
|
||||
sourceAccessMethod: SourceAccessCoordinator(
|
||||
connectedDeviceAccess: connectedDeviceAccess
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SourcesSidebarView(
|
||||
@ -28,6 +44,7 @@ struct ContentView: View {
|
||||
selection: $selectedSidebarSelection,
|
||||
footerState: library.sidebarFooterState,
|
||||
addSourceAction: pickFolder,
|
||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||
rescanSourceAction: { source in
|
||||
selectedSidebarSelection = .allContent(sourceID: source.id)
|
||||
selectedItemID = nil
|
||||
@ -104,6 +121,18 @@ struct ContentView: View {
|
||||
LaunchRestoreOverlayView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingDeviceSourceSheet) {
|
||||
ConnectedDeviceSourcePickerView(
|
||||
deviceDiscoveryService: connectedDeviceAccess,
|
||||
sourceFactory: deviceSourceFactory,
|
||||
onAddSource: { source in
|
||||
let sourceID = library.addSource(source, shouldPersist: false, shouldScan: true)
|
||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
||||
selectedItemID = nil
|
||||
isShowingDeviceSourceSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.disabled(library.isRestoringPersistedSources)
|
||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||
|
||||
@ -27,17 +27,18 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
var indexedDetailCount: Int
|
||||
var lastScanDate: Date?
|
||||
|
||||
init(
|
||||
nonisolated init(
|
||||
sourceID: URL? = nil,
|
||||
folderURL: URL,
|
||||
bookmarkData: Data? = nil,
|
||||
origin: MinecraftSourceOrigin? = nil
|
||||
) {
|
||||
let normalizedURL = folderURL.standardizedFileURL
|
||||
self.id = normalizedURL
|
||||
self.folderURL = normalizedURL
|
||||
let normalizedFolderURL = normalizedSourceURL(folderURL)
|
||||
self.id = normalizedSourceURL(sourceID ?? normalizedFolderURL)
|
||||
self.folderURL = normalizedFolderURL
|
||||
self.origin = origin ?? .localFolder(bookmarkData: bookmarkData)
|
||||
self.bookmarkData = bookmarkData
|
||||
self.displayName = normalizedURL.lastPathComponent
|
||||
self.displayName = normalizedFolderURL.lastPathComponent
|
||||
self.displayItems = []
|
||||
self.rawItems = []
|
||||
self.logicalPacks = []
|
||||
@ -117,6 +118,14 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func normalizedSourceURL(_ url: URL) -> URL {
|
||||
if url.isFileURL {
|
||||
return url.standardizedFileURL
|
||||
}
|
||||
|
||||
return url.standardized
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func uniqued<Key: Hashable>(by keyPath: KeyPath<Element, Key>) -> [Element] {
|
||||
var seen = Set<Key>()
|
||||
|
||||
@ -69,10 +69,12 @@ enum MinecraftSourceKind: String, Hashable, Sendable, Codable {
|
||||
struct PreparedScanRoot: Hashable, Sendable {
|
||||
let sourceID: URL
|
||||
let rootURL: URL
|
||||
let mountPointURL: URL?
|
||||
let cleanupBehavior: CleanupBehavior
|
||||
|
||||
enum CleanupBehavior: Hashable, Sendable {
|
||||
case none
|
||||
case unmount
|
||||
case deleteTemporaryDirectory
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,6 +294,7 @@ struct SidebarColumnPreviewContainer: View {
|
||||
selection: $selection,
|
||||
footerState: PreviewFixtures.sidebarFooter,
|
||||
addSourceAction: {},
|
||||
addDeviceSourceAction: {},
|
||||
rescanSourceAction: { _ in },
|
||||
removeSourceAction: { _ 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 footerResetTask: Task<Void, Never>?
|
||||
private let persistenceStore: SourcePersistenceStore
|
||||
private let scanRootPreparer: SourceScanRootPreparing
|
||||
private let sourceAccessMethod: SourceAccessMethod
|
||||
|
||||
init(
|
||||
persistenceStore: SourcePersistenceStore = .shared,
|
||||
scanRootPreparer: SourceScanRootPreparing = LocalFolderScanRootPreparer()
|
||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess()
|
||||
) {
|
||||
self.persistenceStore = persistenceStore
|
||||
self.scanRootPreparer = scanRootPreparer
|
||||
self.sourceAccessMethod = sourceAccessMethod
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.restorePersistedSources()
|
||||
@ -70,11 +70,35 @@ final class SourceLibrary: ObservableObject {
|
||||
return normalizedURL
|
||||
}
|
||||
|
||||
sources.append(MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData))
|
||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||
persistSourceIfAvailable(withID: normalizedURL)
|
||||
startScan(for: normalizedURL)
|
||||
return normalizedURL
|
||||
let source = MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)
|
||||
return addSource(source, shouldPersist: true, shouldScan: true)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
|
||||
if sources.contains(where: { $0.id == source.id }) {
|
||||
updateSource(source.id) { existingSource in
|
||||
existingSource.origin = source.origin
|
||||
if existingSource.bookmarkData == nil {
|
||||
existingSource.bookmarkData = source.bookmarkData
|
||||
}
|
||||
if existingSource.displayName.isEmpty {
|
||||
existingSource.displayName = source.displayName
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sources.append(source)
|
||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
|
||||
if shouldPersist, source.origin.kind == .localFolder {
|
||||
persistSourceIfAvailable(withID: source.id)
|
||||
}
|
||||
if shouldScan {
|
||||
startScan(for: source.id)
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
func source(withID sourceID: URL) -> MinecraftSource? {
|
||||
@ -169,7 +193,7 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
let preparedScanRoot: PreparedScanRoot
|
||||
do {
|
||||
preparedScanRoot = try await scanRootPreparer.prepareScanRoot(for: source)
|
||||
preparedScanRoot = try await sourceAccessMethod.prepareScanRoot(for: source)
|
||||
} catch {
|
||||
updateSource(sourceID) { source in
|
||||
source.scanError = error.localizedDescription
|
||||
@ -944,11 +968,8 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func cleanupPreparedScanRoot(_ preparedScanRoot: PreparedScanRoot) {
|
||||
switch preparedScanRoot.cleanupBehavior {
|
||||
case .none:
|
||||
return
|
||||
case .unmount:
|
||||
return
|
||||
Task.detached(priority: .utility) { [sourceAccessMethod] in
|
||||
await sourceAccessMethod.releaseScanRoot(preparedScanRoot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ struct SourcesSidebarView: View {
|
||||
@Binding var selection: SidebarSelection?
|
||||
let footerState: SidebarFooterState
|
||||
let addSourceAction: () -> Void
|
||||
let addDeviceSourceAction: () -> Void
|
||||
let rescanSourceAction: (MinecraftSource) -> Void
|
||||
let removeSourceAction: (MinecraftSource) -> Void
|
||||
let revealFooterURLAction: (URL) -> Void
|
||||
@ -77,6 +78,13 @@ struct SourcesSidebarView: View {
|
||||
}
|
||||
.help("Add Source Folder")
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button(action: addDeviceSourceAction) {
|
||||
Image(systemName: "iphone.gen3")
|
||||
}
|
||||
.help("Add Connected Device Source")
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@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 {
|
||||
|
||||
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