world-manager/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift

743 lines
28 KiB
Swift

//
// AppleMobileDeviceSourceAccess.swift
// World Manager for Minecraft
//
// Created by OpenAI on 2026-05-26.
//
import Foundation
struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated let accessorIdentifier: SourceAccessorIdentifier = "connected-device.apple-mobile-device"
nonisolated init() {}
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
_ = source
return SourceAccessDescriptor(
accessorIdentifier: accessorIdentifier,
kind: .connectedDevice,
refreshStrategy: .staged
)
}
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability {
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
return .unavailable
}
do {
let devices = try await listConnectedDevices()
guard let device = devices.first(where: { $0.udid == expectedDevice.udid }) else {
return .disconnected
}
switch device.trustState {
case .trusted:
return .available
case .locked, .untrusted:
return .limited
case .unavailable:
return .disconnected
}
} catch {
return .disconnected
}
}
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
let devices = try await AppleMobileDeviceAccess.connectedDevices()
return devices.compactMap { device in
let connection: DeviceConnection
switch device.connectionType.lowercased() {
case "network", "wifi", "wi-fi":
connection = .network
default:
connection = .usb
}
return ConnectedDevice(
udid: device.deviceIdentifier,
name: device.deviceName,
productType: device.productType.isEmpty ? nil : device.productType,
osVersion: device.productVersion.isEmpty ? nil : device.productVersion,
connection: connection,
trustState: device.trustState
)
}
}
nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] {
let applications = try await AppleMobileDeviceAccess.listApplications(deviceIdentifier: device.udid)
return applications
.filter { application in
application.fileSharingEnabled
|| application.supportsOpeningDocumentsInPlace
|| application.bundleIdentifier == "com.mojang.minecraftpe"
}
.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 discoverItems(
for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws {
_ = mode
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 summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: requestedSubpath
)
let metadataByPath = try await metadataByRelativePath(
for: summaries,
container: container,
requestedSubpath: requestedSubpath
)
let items = summaries.compactMap { summary in
makeItem(
from: summary,
metadata: metadataByPath[summary.relativePath],
source: source
)
}
for item in items {
onDiscovered(item)
}
}
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
var enrichedItem = item
guard case .connectedDevice = source.origin else {
enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
return enrichedItem
}
enrichedItem.modifiedDate = nil
enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = !enrichedItem.hasKnownIcon
enrichedItem.sizeLoaded = false
return enrichedItem
}
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
var previewItem = item
guard case .connectedDevice(_, let container) = source.origin else {
previewItem.previewLoaded = true
return previewItem
}
if previewItem.hasKnownIcon {
previewItem.iconURL = await loadRemoteIcon(
for: previewItem,
source: source,
container: container
)
}
previewItem.previewLoaded = true
return previewItem
}
nonisolated func loadPreviewAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] {
guard case .connectedDevice(_, let container) = source.origin else {
var previewItems: [MinecraftContentItem] = []
previewItems.reserveCapacity(items.count)
for item in items {
previewItems.append(await loadPreviewAssets(for: item, in: source))
}
return previewItems
}
let summaries = items.compactMap { item -> AppleMobileMinecraftLibraryItemSummary? in
guard item.hasKnownIcon, let relativePath = relativeItemPath(for: item, in: source) else {
return nil
}
return AppleMobileMinecraftLibraryItemSummary(
contentType: item.contentType.rawValue,
collectionFolderName: item.collectionRootURL.lastPathComponent,
relativePath: relativePath,
folderName: item.folderName,
displayName: item.displayName,
hasIcon: true
)
}
let iconsByRelativePath: [String: URL]
if summaries.isEmpty {
iconsByRelativePath = [:]
} else if let iconSummaries = try? await AppleMobileDeviceAccess.minecraftIconBatch(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
items: summaries
) {
var resolvedIcons: [String: URL] = [:]
resolvedIcons.reserveCapacity(iconSummaries.count)
for iconSummary in iconSummaries {
let pathExtension = NSString(string: iconSummary.iconFileName).pathExtension
let cachedURL = await ImageCacheStore.shared.cachedImageURL(
forRemoteData: iconSummary.data,
cacheKey: "\(container.deviceUDID)::\(container.appID)::\(iconSummary.relativePath)::\(iconSummary.iconFileName)",
pathExtension: pathExtension
)
if let cachedURL {
resolvedIcons[iconSummary.relativePath] = cachedURL
}
}
iconsByRelativePath = resolvedIcons
} else {
iconsByRelativePath = [:]
}
return items.map { item in
var previewItem = item
if let relativePath = relativeItemPath(for: item, in: source),
let cachedURL = iconsByRelativePath[relativePath] {
previewItem.iconURL = cachedURL
}
previewItem.previewLoaded = true
return previewItem
}
}
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
var sizedItem = item
guard case .connectedDevice(_, let container) = source.origin else {
sizedItem.sizeLoaded = true
return sizedItem
}
if let remoteItemPath = remoteItemPath(for: item, in: source),
let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteItemPath
) {
sizedItem.sizeBytes = metrics.sizeBytes
if sizedItem.modifiedDate == nil {
sizedItem.modifiedDate = metrics.modifiedDate
}
}
sizedItem.sizeLoaded = true
return sizedItem
}
nonisolated func loadSizeAssets(for items: [MinecraftContentItem], in source: MinecraftSource) async -> [MinecraftContentItem] {
guard case .connectedDevice(_, let container) = source.origin else {
var sizedItems: [MinecraftContentItem] = []
sizedItems.reserveCapacity(items.count)
for item in items {
sizedItems.append(await loadSize(for: item, in: source))
}
return sizedItems
}
let relativePathsByItemID = Dictionary(uniqueKeysWithValues: items.compactMap { item in
remoteItemPath(for: item, in: source).map { (item.id, $0) }
})
let metricsByRelativePath: [String: AppleMobileDevicePathMetrics]
if relativePathsByItemID.isEmpty {
metricsByRelativePath = [:]
} else if let metricSummaries = try? await AppleMobileDeviceAccess.pathMetricsBatch(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePaths: Array(relativePathsByItemID.values)
) {
metricsByRelativePath = Dictionary(
uniqueKeysWithValues: metricSummaries.map { ($0.relativePath, $0.metrics) }
)
} else {
metricsByRelativePath = [:]
}
return items.map { item in
var sizedItem = item
if let relativePath = relativePathsByItemID[item.id],
let metrics = metricsByRelativePath[relativePath] {
sizedItem.sizeBytes = metrics.sizeBytes
if sizedItem.modifiedDate == nil {
sizedItem.modifiedDate = metrics.modifiedDate
}
}
sizedItem.sizeLoaded = true
return sizedItem
}
}
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
guard case .connectedDevice(_, let container) = source.origin else {
return []
}
guard let remoteFolderPath = remoteItemPath(for: item, in: source) else {
return []
}
let entries = try await AppleMobileDeviceAccess.listDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteFolderPath
)
return entries
.map { entry in
let isDirectory = !NSString(string: entry).pathExtension.isEmpty ? false : true
return DirectoryPreviewEntry(name: entry, isDirectory: isDirectory)
}
.sorted { lhs, rhs in
if lhs.isDirectory != rhs.isDirectory {
return lhs.isDirectory && !rhs.isDirectory
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL {
guard case .connectedDevice(_, let container) = source.origin else {
return item.folderURL
}
guard let remoteItemPath = remoteItemPath(for: item, in: source) else {
throw SourceAccessError.accessFailed(reason: "Could not resolve the device path for this item.")
}
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent("WMMConnectedDeviceReveal", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
do {
try await AppleMobileDeviceAccess.mirrorSubtree(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL
)
return destinationURL
} catch {
try? FileManager.default.removeItem(at: destinationURL)
throw error
}
}
nonisolated func purgeCachedArtifacts(for source: MinecraftSource) async {
guard source.origin.kind == .connectedDevice else {
return
}
try? ConnectedDeviceMirrorCache.purgeRootURL(for: source.id)
}
nonisolated private func makeItem(
from summary: AppleMobileMinecraftLibraryItemSummary,
metadata: AppleMobileMinecraftItemMetadataSummary?,
source: MinecraftSource
) -> MinecraftContentItem? {
let contentType: MinecraftContentType
switch summary.contentType {
case MinecraftContentType.world.rawValue:
contentType = .world
case MinecraftContentType.behaviorPack.rawValue:
contentType = .behaviorPack
case MinecraftContentType.resourcePack.rawValue:
contentType = .resourcePack
case MinecraftContentType.skinPack.rawValue:
contentType = .skinPack
case MinecraftContentType.worldTemplate.rawValue:
contentType = .worldTemplate
default:
return nil
}
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, isDirectory: true)
let displayName = metadata?.displayName ?? summary.displayName
let packMetadataDetails: PackMetadataDetails?
if let minimumEngineVersion = metadata?.minimumEngineVersion {
packMetadataDetails = PackMetadataDetails(minimumEngineVersion: minimumEngineVersion)
} else {
packMetadataDetails = nil
}
return MinecraftContentItem(
folderURL: folderURL,
folderName: summary.folderName,
contentType: contentType,
collectionRootURL: collectionRootURL,
displayName: displayName,
iconURL: nil,
hasKnownIcon: summary.hasIcon,
packUUID: metadata?.packUUID,
packVersion: metadata?.packVersion,
packMetadataDetails: packMetadataDetails,
packReferences: packReferences(from: metadata?.packReferences ?? []),
metadataLoaded: false,
previewLoaded: !summary.hasIcon,
sizeLoaded: false
)
}
nonisolated private func metadataByRelativePath(
for summaries: [AppleMobileMinecraftLibraryItemSummary],
container: DeviceAppContainer,
requestedSubpath: String
) async throws -> [String: AppleMobileMinecraftItemMetadataSummary] {
guard !summaries.isEmpty else {
return [:]
}
let metadata = try await AppleMobileDeviceAccess.minecraftMetadataBatch(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: requestedSubpath,
items: summaries
)
return Dictionary(uniqueKeysWithValues: metadata.map { ($0.relativePath, $0) })
}
nonisolated private func packReferences(
from summaries: [AppleMobilePackReferenceSummary]
) -> [ContentPackReference] {
let references = summaries.compactMap { summary -> ContentPackReference? in
let contentType: MinecraftContentType
switch summary.contentType {
case MinecraftContentType.behaviorPack.rawValue:
contentType = .behaviorPack
case MinecraftContentType.resourcePack.rawValue:
contentType = .resourcePack
case MinecraftContentType.skinPack.rawValue:
contentType = .skinPack
case MinecraftContentType.worldTemplate.rawValue:
contentType = .worldTemplate
default:
return nil
}
let source: PackSource
switch summary.source {
case PackSource.embeddedInWorld.rawValue:
source = .embeddedInWorld
case PackSource.foundInCollection.rawValue:
source = .foundInCollection
default:
source = .referencedByWorld
}
return ContentPackReference(
name: summary.name,
type: contentType,
iconURL: nil,
uuid: summary.uuid,
version: summary.version,
source: source
)
}
return uniquePackReferences(references)
}
nonisolated private func remoteItemPath(
for item: MinecraftContentItem,
in source: MinecraftSource,
appending childPath: String? = nil
) -> String? {
guard case .connectedDevice(_, let container) = source.origin else {
return nil
}
let rootPath = container.minecraftFolderRelativePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !rootPath.isEmpty else {
return nil
}
let relativeItemPath = relativeItemPath(for: item, in: source) ?? ""
guard !relativeItemPath.isEmpty else {
return nil
}
let basePath = appendPathComponents(
rootPath,
components: relativeItemPath.split(separator: "/").map(String.init)
)
if let childPath, !childPath.isEmpty {
return NSString(string: basePath).appendingPathComponent(childPath)
}
return basePath
}
nonisolated private func relativeItemPath(for item: MinecraftContentItem, in source: MinecraftSource) -> String? {
let relativeItemPath = item.folderURL.path.replacingOccurrences(of: source.folderURL.path + "/", with: "")
return relativeItemPath.isEmpty ? nil : relativeItemPath
}
nonisolated private func loadRemoteIcon(
for item: MinecraftContentItem,
source: MinecraftSource,
container: DeviceAppContainer
) async -> URL? {
let candidateNames: [String]
switch item.contentType {
case .world:
candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
}
for candidateName in candidateNames {
guard let remotePath = remoteItemPath(for: item, in: source, appending: candidateName) else {
continue
}
guard let data = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remotePath
) else {
continue
}
let pathExtension = NSString(string: candidateName).pathExtension
return await ImageCacheStore.shared.cachedImageURL(
forRemoteData: data,
cacheKey: "\(container.deviceUDID)::\(container.appID)::\(remotePath)",
pathExtension: pathExtension
)
}
return nil
}
nonisolated private func appendPathComponents(_ root: String, components: [String]) -> String {
components.reduce(root) { partial, component in
NSString(string: partial).appendingPathComponent(component)
}
}
nonisolated private func loadWorldPackReferences(
for item: MinecraftContentItem,
source: MinecraftSource,
container: DeviceAppContainer
) async -> [ContentPackReference] {
var references: [ContentPackReference] = []
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
let behaviorData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: behaviorRefPath
) {
references.append(contentsOf: parsePackReferences(from: behaviorData, type: .behaviorPack))
}
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
let resourceData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: resourceRefPath
) {
references.append(contentsOf: parsePackReferences(from: resourceData, type: .resourcePack))
}
references.append(contentsOf: await loadEmbeddedPackReferences(
for: item,
source: source,
container: container,
folderName: "behavior_packs",
type: .behaviorPack
))
references.append(contentsOf: await loadEmbeddedPackReferences(
for: item,
source: source,
container: container,
folderName: "resource_packs",
type: .resourcePack
))
return uniquePackReferences(references)
}
nonisolated private func loadEmbeddedPackReferences(
for item: MinecraftContentItem,
source: MinecraftSource,
container: DeviceAppContainer,
folderName: String,
type: MinecraftContentType
) async -> [ContentPackReference] {
guard let remoteFolderPath = remoteItemPath(for: item, in: source, appending: folderName) else {
return []
}
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteFolderPath
) else {
return []
}
var references: [ContentPackReference] = []
for childFolder in childFolders {
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: manifestPath
) else {
continue
}
guard let metadata = parseManifestMetadata(from: manifestData, fallbackName: childFolder) else {
continue
}
references.append(
ContentPackReference(
name: metadata.name,
type: type,
iconURL: nil,
uuid: metadata.uuid,
version: metadata.version,
source: .embeddedInWorld
)
)
}
return references
}
nonisolated private func parsePackReferences(
from data: Data,
type: MinecraftContentType
) -> [ContentPackReference] {
guard let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
return []
}
return jsonObject.map { entry in
let uuid = (entry["pack_id"] as? String)?.lowercased()
let version = versionString(from: entry["version"])
return ContentPackReference(
name: uuid ?? "Referenced Pack",
type: type,
iconURL: nil,
uuid: uuid,
version: version,
source: .referencedByWorld
)
}
}
nonisolated private func parseManifestMetadata(
from data: Data,
fallbackName: String
) -> (name: String, uuid: String?, version: String?, minimumEngineVersion: String?)? {
guard
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let header = jsonObject["header"] as? [String: Any]
else {
return nil
}
let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
$0.isEmpty ? nil : $0
} ?? fallbackName
return (
name: name,
uuid: (header["uuid"] as? String)?.lowercased(),
version: versionString(from: header["version"]),
minimumEngineVersion: versionString(from: header["min_engine_version"])
)
}
nonisolated private func versionString(from value: Any?) -> String? {
if let versionString = value as? String, !versionString.isEmpty {
return versionString
}
if let versionArray = value as? [Any] {
let components = versionArray.compactMap { component -> String? in
if let intComponent = component as? Int {
return String(intComponent)
}
if let stringComponent = component as? String {
return stringComponent
}
return nil
}
return components.isEmpty ? nil : components.joined(separator: ".")
}
return nil
}
nonisolated private func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
var seen = Set<String>()
var uniqueReferences: [ContentPackReference] = []
for reference in references {
let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""]
.joined(separator: "::")
guard seen.insert(dedupeKey).inserted else {
continue
}
uniqueReferences.append(reference)
}
return uniqueReferences.sorted { lhs, rhs in
if lhs.type != rhs.type {
return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
}