570 lines
22 KiB
Swift
570 lines
22 KiB
Swift
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
|
|
struct AppleMobileDeviceSummary: Sendable {
|
|
let deviceName: String
|
|
let deviceIdentifier: String
|
|
let productType: String
|
|
let productVersion: String
|
|
let connectionType: String
|
|
let trustState: DeviceTrustState
|
|
}
|
|
|
|
struct AppleMobileDeviceApplicationSummary: Sendable {
|
|
let bundleIdentifier: String
|
|
let displayName: String
|
|
let fileSharingEnabled: Bool
|
|
let supportsOpeningDocumentsInPlace: Bool
|
|
}
|
|
|
|
struct AppleMobileMinecraftLibraryItemSummary: Sendable {
|
|
let contentType: String
|
|
let collectionFolderName: String
|
|
let relativePath: String
|
|
let folderName: String
|
|
let displayName: String
|
|
let hasIcon: Bool
|
|
}
|
|
|
|
struct AppleMobilePackReferenceSummary: Sendable {
|
|
let name: String
|
|
let contentType: String
|
|
let uuid: String?
|
|
let version: String?
|
|
let source: String
|
|
}
|
|
|
|
struct AppleMobileMinecraftItemMetadataSummary: Sendable {
|
|
let relativePath: String
|
|
let displayName: String?
|
|
let packUUID: String?
|
|
let packVersion: String?
|
|
let minimumEngineVersion: String?
|
|
let packReferences: [AppleMobilePackReferenceSummary]
|
|
}
|
|
|
|
struct AppleMobileMinecraftIconSummary: Sendable {
|
|
let relativePath: String
|
|
let iconFileName: String
|
|
let data: Data
|
|
}
|
|
|
|
struct AppleMobileDevicePathMetrics: Sendable {
|
|
let sizeBytes: Int64?
|
|
let modifiedDate: Date?
|
|
}
|
|
|
|
struct AppleMobileDevicePathMetricsSummary: Sendable {
|
|
let relativePath: String
|
|
let metrics: AppleMobileDevicePathMetrics
|
|
}
|
|
|
|
actor AppleMobileDeviceOperationLimiter {
|
|
static let shared = AppleMobileDeviceOperationLimiter()
|
|
|
|
private var activeDevices = Set<String>()
|
|
private var waitingContinuations: [String: [CheckedContinuation<Void, Never>]] = [:]
|
|
|
|
func run<T: Sendable>(
|
|
for deviceIdentifier: String,
|
|
operation: @Sendable () async throws -> T
|
|
) async throws -> T {
|
|
await acquire(deviceIdentifier: deviceIdentifier)
|
|
defer { release(deviceIdentifier: deviceIdentifier) }
|
|
return try await operation()
|
|
}
|
|
|
|
private func acquire(deviceIdentifier: String) async {
|
|
guard activeDevices.contains(deviceIdentifier) else {
|
|
activeDevices.insert(deviceIdentifier)
|
|
return
|
|
}
|
|
|
|
await withCheckedContinuation { continuation in
|
|
waitingContinuations[deviceIdentifier, default: []].append(continuation)
|
|
}
|
|
}
|
|
|
|
private func release(deviceIdentifier: String) {
|
|
guard var queuedContinuations = waitingContinuations[deviceIdentifier], !queuedContinuations.isEmpty else {
|
|
activeDevices.remove(deviceIdentifier)
|
|
waitingContinuations[deviceIdentifier] = nil
|
|
return
|
|
}
|
|
|
|
let continuation = queuedContinuations.removeFirst()
|
|
waitingContinuations[deviceIdentifier] = queuedContinuations.isEmpty ? nil : queuedContinuations
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
enum AppleMobileDeviceAccess {
|
|
static func connectedDevices() async throws -> [AppleMobileDeviceSummary] {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceSummaries(&error) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "No connected device could be read from MobileDevice.framework."]
|
|
)
|
|
}
|
|
|
|
guard let rawDevices = response["devices"] as? [[String: Any]] else {
|
|
throw NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice summary returned an unexpected payload."]
|
|
)
|
|
}
|
|
|
|
return try rawDevices.map { device in
|
|
guard
|
|
let deviceName = device["deviceName"] as? String,
|
|
let deviceIdentifier = device["deviceIdentifier"] as? String,
|
|
let productType = device["productType"] as? String,
|
|
let productVersion = device["productVersion"] as? String,
|
|
let connectionType = device["connectionType"] as? String,
|
|
let trustStateRawValue = device["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,
|
|
connectionType: connectionType,
|
|
trustState: trustState
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
|
|
static func mirrorSubtree(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String,
|
|
destinationDirectoryURL: URL
|
|
) async throws {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
let didCopy = WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
destinationDirectoryURL,
|
|
&error
|
|
)
|
|
|
|
if !didCopy {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."]
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceApplicationList(deviceIdentifier, &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: flexibleBool(from: application["uiFileSharingEnabled"]),
|
|
supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func listDirectory(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String
|
|
) async throws -> [String] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceAppDirectoryListing(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 7,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
|
|
)
|
|
}
|
|
|
|
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func fileData(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String
|
|
) async throws -> Data {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
guard let data = WMMCopyConnectedDeviceAppFileData(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 8,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
|
|
)
|
|
}
|
|
|
|
return data as Data
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func minecraftLibrarySnapshot(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String
|
|
) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 5,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
|
|
)
|
|
}
|
|
|
|
guard let rawItems = response["items"] as? [[String: Any]] else {
|
|
throw NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 6,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
|
|
)
|
|
}
|
|
|
|
return rawItems.compactMap { item in
|
|
guard
|
|
let contentType = item["contentType"] as? String,
|
|
let collectionFolderName = item["collectionFolderName"] as? String,
|
|
let relativePath = item["relativePath"] as? String,
|
|
let folderName = item["folderName"] as? String,
|
|
let displayName = item["displayName"] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return AppleMobileMinecraftLibraryItemSummary(
|
|
contentType: contentType,
|
|
collectionFolderName: collectionFolderName,
|
|
relativePath: relativePath,
|
|
folderName: folderName,
|
|
displayName: displayName,
|
|
hasIcon: flexibleBool(from: item["hasIcon"])
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func minecraftMetadataBatch(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String,
|
|
items: [AppleMobileMinecraftLibraryItemSummary]
|
|
) async throws -> [AppleMobileMinecraftItemMetadataSummary] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
let requestItems = items.map { item in
|
|
[
|
|
"contentType": item.contentType,
|
|
"relativePath": item.relativePath,
|
|
"folderName": item.folderName
|
|
]
|
|
}
|
|
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceMinecraftMetadataBatch(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
requestItems,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 10,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch failed."]
|
|
)
|
|
}
|
|
|
|
guard let rawItems = response["items"] as? [[String: Any]] else {
|
|
throw NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 11,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch returned an unexpected payload."]
|
|
)
|
|
}
|
|
|
|
return rawItems.compactMap { item in
|
|
guard let relativePath = item["relativePath"] as? String else {
|
|
return nil
|
|
}
|
|
|
|
return AppleMobileMinecraftItemMetadataSummary(
|
|
relativePath: relativePath,
|
|
displayName: item["displayName"] as? String,
|
|
packUUID: (item["packUUID"] as? String)?.lowercased(),
|
|
packVersion: item["packVersion"] as? String,
|
|
minimumEngineVersion: item["minimumEngineVersion"] as? String,
|
|
packReferences: (item["packReferences"] as? [[String: Any]] ?? []).compactMap { reference in
|
|
guard
|
|
let name = reference["name"] as? String,
|
|
let contentType = reference["contentType"] as? String,
|
|
let source = reference["source"] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return AppleMobilePackReferenceSummary(
|
|
name: name,
|
|
contentType: contentType,
|
|
uuid: (reference["uuid"] as? String)?.lowercased(),
|
|
version: reference["version"] as? String,
|
|
source: source
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func minecraftIconBatch(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String,
|
|
items: [AppleMobileMinecraftLibraryItemSummary]
|
|
) async throws -> [AppleMobileMinecraftIconSummary] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
let requestItems = items.map { item in
|
|
[
|
|
"contentType": item.contentType,
|
|
"relativePath": item.relativePath
|
|
]
|
|
}
|
|
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceMinecraftIconBatch(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
requestItems,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 12,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice icon batch failed."]
|
|
)
|
|
}
|
|
|
|
guard let rawItems = response["items"] as? [[String: Any]] else {
|
|
throw NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 13,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice icon batch returned an unexpected payload."]
|
|
)
|
|
}
|
|
|
|
return rawItems.compactMap { item in
|
|
guard
|
|
let relativePath = item["relativePath"] as? String,
|
|
let iconFileName = item["iconFileName"] as? String,
|
|
let data = item["data"] as? Data
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return AppleMobileMinecraftIconSummary(
|
|
relativePath: relativePath,
|
|
iconFileName: iconFileName,
|
|
data: data
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func pathMetrics(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePath: String
|
|
) async throws -> AppleMobileDevicePathMetrics {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .utility) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceAppPathMetrics(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePath,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 9,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
|
|
)
|
|
}
|
|
|
|
let rawSize = response["sizeBytes"]
|
|
let sizeBytes: Int64?
|
|
switch rawSize {
|
|
case let number as NSNumber:
|
|
sizeBytes = number.int64Value
|
|
case let value as Int64:
|
|
sizeBytes = value
|
|
case let value as Int:
|
|
sizeBytes = Int64(value)
|
|
default:
|
|
sizeBytes = nil
|
|
}
|
|
|
|
return AppleMobileDevicePathMetrics(
|
|
sizeBytes: sizeBytes,
|
|
modifiedDate: response["modifiedDate"] as? Date
|
|
)
|
|
}.value
|
|
}
|
|
}
|
|
|
|
static func pathMetricsBatch(
|
|
deviceIdentifier: String,
|
|
bundleIdentifier: String,
|
|
relativePaths: [String]
|
|
) async throws -> [AppleMobileDevicePathMetricsSummary] {
|
|
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
|
try await Task.detached(priority: .utility) {
|
|
var error: NSError?
|
|
guard let response = WMMCopyConnectedDeviceAppPathMetricsBatch(
|
|
deviceIdentifier,
|
|
bundleIdentifier,
|
|
relativePaths,
|
|
&error
|
|
) else {
|
|
throw error ?? NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 14,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics batch lookup failed."]
|
|
)
|
|
}
|
|
|
|
guard let rawItems = response["items"] as? [[String: Any]] else {
|
|
throw NSError(
|
|
domain: "AppleMobileDeviceAccess",
|
|
code: 15,
|
|
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics batch returned an unexpected payload."]
|
|
)
|
|
}
|
|
|
|
return rawItems.compactMap { item in
|
|
guard let relativePath = item["relativePath"] as? String else {
|
|
return nil
|
|
}
|
|
|
|
let rawSize = item["sizeBytes"]
|
|
let sizeBytes: Int64?
|
|
switch rawSize {
|
|
case let number as NSNumber:
|
|
sizeBytes = number.int64Value
|
|
case let value as Int64:
|
|
sizeBytes = value
|
|
case let value as Int:
|
|
sizeBytes = Int64(value)
|
|
default:
|
|
sizeBytes = nil
|
|
}
|
|
|
|
return AppleMobileDevicePathMetricsSummary(
|
|
relativePath: relativePath,
|
|
metrics: AppleMobileDevicePathMetrics(
|
|
sizeBytes: sizeBytes,
|
|
modifiedDate: item["modifiedDate"] as? Date
|
|
)
|
|
)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
nonisolated private static func flexibleBool(from value: Any?) -> Bool {
|
|
switch value {
|
|
case let value as Bool:
|
|
return value
|
|
case let value as NSNumber:
|
|
return value.boolValue
|
|
case let value as NSString:
|
|
return value.boolValue
|
|
case let value as String:
|
|
return NSString(string: value).boolValue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|