Files
ggbond268--mactools/Plugins/DeviceBattery/Sources/DeviceBatteryMobileDeviceReader.swift
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

603 lines
20 KiB
Swift

import Darwin
import Foundation
protocol DeviceBatteryMobileDeviceSampling: Sendable {
func collectDevices(
referenceDate: Date,
minimumRefreshInterval: TimeInterval
) async -> [DeviceBatteryItem]
}
actor DeviceBatteryMobileDeviceReader: DeviceBatteryMobileDeviceSampling {
private var cachedItems: [DeviceBatteryItem] = []
private var lastCollectionDate: Date?
private var inFlightTask: (id: UUID, task: Task<[DeviceBatteryMobileDeviceRecord], Never>)?
func collectDevices(
referenceDate: Date,
minimumRefreshInterval: TimeInterval
) async -> [DeviceBatteryItem] {
if let lastCollectionDate,
referenceDate.timeIntervalSince(lastCollectionDate) < minimumRefreshInterval {
return cachedItems
}
let requestID: UUID
let task: Task<[DeviceBatteryMobileDeviceRecord], Never>
if let inFlightTask {
requestID = inFlightTask.id
task = inFlightTask.task
} else {
requestID = UUID()
let newTask = Task.detached(priority: .utility) {
DeviceBatteryMobileDeviceBridge.readRecords()
}
inFlightTask = (requestID, newTask)
task = newTask
}
let records = await task.value
if inFlightTask?.id == requestID {
inFlightTask = nil
}
let items = records.map { $0.batteryItem(referenceDate: referenceDate) }
cachedItems = items
lastCollectionDate = referenceDate
return items
}
}
enum DeviceBatteryMobileDeviceCategory: Equatable, Sendable {
case phone
case tablet
case mediaPlayer
case watch
case spatialComputer
case other
var itemKind: DeviceBatteryKind {
switch self {
case .phone:
return .phone
case .tablet:
return .tablet
case .mediaPlayer:
return .mediaPlayer
case .watch:
return .watch
case .spatialComputer:
return .spatialComputer
case .other:
return .other
}
}
static func resolve(deviceClass: String?, productType: String) -> Self {
let value = [deviceClass, productType]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
if value.contains("iphone") || value.contains("mobilephone") {
return .phone
}
if value.contains("ipad") || value.contains("tablet") {
return .tablet
}
if value.contains("ipod") {
return .mediaPlayer
}
if value.contains("watch") {
return .watch
}
if value.contains("reality") || value.contains("vision") {
return .spatialComputer
}
return .other
}
}
struct DeviceBatteryMobileDeviceRecord: Equatable, Sendable {
let identifier: String
let name: String
let productType: String
let category: DeviceBatteryMobileDeviceCategory
let level: Int
let chargeState: DeviceBatteryChargeState
let connectionType: String
let parentName: String?
func batteryItem(referenceDate: Date) -> DeviceBatteryItem {
DeviceBatteryItem(
id: "mobile-device-\(identifier)",
name: name,
model: productType.isEmpty ? nil : productType,
kind: category.itemKind,
level: level,
chargeState: chargeState,
parentName: parentName,
source: "MobileDevice",
lastUpdated: referenceDate,
isConnected: true,
detail: connectionType.isEmpty ? nil : connectionType,
componentIdentity: nil
)
}
}
enum DeviceBatteryMobileBatteryParser {
static func record(
identifier: String,
name: String,
productType: String,
deviceClass: String?,
connectionType: String,
parentName: String? = nil,
battery: [String: Any]
) -> DeviceBatteryMobileDeviceRecord? {
guard let level = batteryLevel(from: battery) else {
return nil
}
return DeviceBatteryMobileDeviceRecord(
identifier: identifier,
name: name.isEmpty ? fallbackName(deviceClass: deviceClass, productType: productType) : name,
productType: productType,
category: DeviceBatteryMobileDeviceCategory.resolve(
deviceClass: deviceClass,
productType: productType
),
level: level,
chargeState: chargeState(from: battery),
connectionType: connectionType,
parentName: parentName
)
}
static func batteryLevel(from battery: [String: Any]) -> Int? {
if let capacity = intValue(battery["BatteryCurrentCapacity"]), (0...100).contains(capacity) {
return capacity
}
let current = firstPositiveInt(
battery["AppleRawCurrentCapacity"],
battery["CurrentCapacity"]
)
let maximum = firstPositiveInt(
battery["AppleRawMaxCapacity"],
battery["NominalChargeCapacity"],
battery["MaxCapacity"]
)
guard let current, let maximum, maximum > 0 else {
return nil
}
return min(max(Int((Double(current) / Double(maximum) * 100).rounded()), 0), 100)
}
static func chargeState(from battery: [String: Any]) -> DeviceBatteryChargeState {
if boolValue(battery["BatteryIsFullyCharged"])
|| boolValue(battery["FullyCharged"]) {
return .charged
}
if boolValue(battery["BatteryIsCharging"])
|| boolValue(battery["IsCharging"]) {
return .charging
}
if boolValue(battery["ExternalConnected"])
|| boolValue(battery["AppleRawExternalConnected"]) {
return .plugged
}
return .normal
}
private static func fallbackName(deviceClass: String?, productType: String) -> String {
switch DeviceBatteryMobileDeviceCategory.resolve(deviceClass: deviceClass, productType: productType) {
case .phone:
return "iPhone"
case .tablet:
return "iPad"
case .mediaPlayer:
return "iPod touch"
case .watch:
return "Apple Watch"
case .spatialComputer:
return "Apple Vision Pro"
case .other:
return productType.isEmpty ? "Apple 设备" : productType
}
}
private static func firstPositiveInt(_ values: Any?...) -> Int? {
values.lazy.compactMap(intValue).first { $0 > 0 }
}
private static func intValue(_ value: Any?) -> Int? {
if let number = value as? NSNumber {
return number.intValue
}
if let value = value as? Int {
return value
}
if let value = value as? String {
return Int(value)
}
return nil
}
private static func boolValue(_ value: Any?) -> Bool {
if let number = value as? NSNumber {
return number.boolValue
}
if let value = value as? Bool {
return value
}
if let value = value as? String {
return value.caseInsensitiveCompare("true") == .orderedSame
|| value.caseInsensitiveCompare("yes") == .orderedSame
|| value == "1"
}
return false
}
}
private enum DeviceBatteryMobileDeviceBridge {
private static let frameworkPath = "/System/Library/PrivateFrameworks/MobileDevice.framework/MobileDevice"
private static let success: Int32 = 0
private static let serviceTimeoutSeconds: Int = 2
private static let lock = NSLock()
private typealias DevicePointer = UnsafeMutableRawPointer
private typealias CreateDeviceListFunction = @convention(c) () -> Unmanaged<CFArray>?
private typealias DeviceResultFunction = @convention(c) (DevicePointer) -> Int32
private typealias CopyDeviceIdentifierFunction = @convention(c) (DevicePointer) -> Unmanaged<CFString>?
private typealias CopyValueFunction = @convention(c) (DevicePointer, CFString?, CFString?) -> Unmanaged<CFTypeRef>?
private typealias StartServiceFunction = @convention(c) (
DevicePointer,
CFString,
CFDictionary?,
UnsafeMutablePointer<DevicePointer?>
) -> Int32
private typealias SendMessageFunction = @convention(c) (
DevicePointer,
CFTypeRef,
CFPropertyListFormat
) -> Int32
private typealias ReceiveMessageFunction = @convention(c) (
DevicePointer,
UnsafeMutablePointer<Unmanaged<CFTypeRef>?>,
UnsafeMutablePointer<CFPropertyListFormat>
) -> Int32
private struct Symbols: @unchecked Sendable {
let createDeviceList: CreateDeviceListFunction
let connect: DeviceResultFunction
let disconnect: DeviceResultFunction
let validatePairing: DeviceResultFunction
let startSession: DeviceResultFunction
let stopSession: DeviceResultFunction
let interfaceType: DeviceResultFunction
let copyDeviceIdentifier: CopyDeviceIdentifierFunction
let copyValue: CopyValueFunction
let startService: StartServiceFunction
let sendMessage: SendMessageFunction
let receiveMessage: ReceiveMessageFunction
let invalidateConnection: DeviceResultFunction
let connectionSocket: DeviceResultFunction
}
private static let symbols: Symbols? = loadSymbols()
static func readRecords() -> [DeviceBatteryMobileDeviceRecord] {
lock.lock()
defer { lock.unlock() }
guard let symbols,
let unmanagedList = symbols.createDeviceList()
else {
return []
}
let list = unmanagedList.takeRetainedValue()
var records: [DeviceBatteryMobileDeviceRecord] = []
for index in 0..<CFArrayGetCount(list) {
guard let value = CFArrayGetValueAtIndex(list, index) else {
continue
}
let device = UnsafeMutableRawPointer(mutating: value)
records.append(contentsOf: readDevice(device, symbols: symbols))
}
return records
}
private static func readDevice(
_ device: DevicePointer,
symbols: Symbols
) -> [DeviceBatteryMobileDeviceRecord] {
guard symbols.connect(device) == success else {
return []
}
defer { _ = symbols.disconnect(device) }
guard symbols.validatePairing(device) == success,
symbols.startSession(device) == success
else {
return []
}
defer { _ = symbols.stopSession(device) }
let identifier = symbols.copyDeviceIdentifier(device)?.takeRetainedValue() as String? ?? ""
guard !identifier.isEmpty else {
return []
}
let name = copiedString("DeviceName", device: device, symbols: symbols)
let productType = copiedString("ProductType", device: device, symbols: symbols)
let deviceClass = copiedString("DeviceClass", device: device, symbols: symbols)
let connectionType = connectionLabel(symbols.interfaceType(device))
let directBattery = copiedDictionary(
domain: "com.apple.mobile.battery",
key: nil,
device: device,
symbols: symbols
)
let battery = directBattery.flatMap {
DeviceBatteryMobileBatteryParser.batteryLevel(from: $0) == nil ? nil : $0
} ?? readIORegistryBattery(device: device, symbols: symbols)
var records: [DeviceBatteryMobileDeviceRecord] = []
if let battery,
let record = DeviceBatteryMobileBatteryParser.record(
identifier: opaqueIdentifier(identifier),
name: name,
productType: productType,
deviceClass: deviceClass,
connectionType: connectionType,
battery: battery
) {
records.append(record)
}
if DeviceBatteryMobileDeviceCategory.resolve(
deviceClass: deviceClass,
productType: productType
) == .phone {
records.append(contentsOf: readPairedWatches(
device: device,
parentName: name,
connectionType: connectionType,
symbols: symbols
))
}
return records
}
private static func readPairedWatches(
device: DevicePointer,
parentName: String,
connectionType: String,
symbols: Symbols
) -> [DeviceBatteryMobileDeviceRecord] {
let registryRequest: [String: Any] = ["Command": "GetDeviceRegistry"]
guard let registry = sendServiceRequest(
service: "com.apple.companion_proxy",
request: registryRequest,
format: .binaryFormat_v1_0,
device: device,
symbols: symbols
),
let identifiers = registry["PairedDevicesArray"] as? [String]
else {
return []
}
return identifiers.compactMap { watchIdentifier in
var values: [String: Any] = [:]
for key in ["DeviceName", "ProductType", "BatteryCurrentCapacity", "BatteryIsCharging"] {
if let value = companionValue(
watchIdentifier: watchIdentifier,
key: key,
device: device,
symbols: symbols
) {
values[key] = value
}
}
return DeviceBatteryMobileBatteryParser.record(
identifier: "watch-\(opaqueIdentifier(watchIdentifier))",
name: values["DeviceName"] as? String ?? "Apple Watch",
productType: values["ProductType"] as? String ?? "Watch",
deviceClass: "Watch",
connectionType: connectionType,
parentName: parentName.isEmpty ? nil : parentName,
battery: values
)
}
}
private static func companionValue(
watchIdentifier: String,
key: String,
device: DevicePointer,
symbols: Symbols
) -> Any? {
let request: [String: Any] = [
"Command": "GetValueFromRegistry",
"GetValueGizmoUDIDKey": watchIdentifier,
"GetValueKeyKey": key
]
guard let response = sendServiceRequest(
service: "com.apple.companion_proxy",
request: request,
format: .binaryFormat_v1_0,
device: device,
symbols: symbols
),
let dictionary = response["RetrievedValueDictionary"] as? [String: Any]
else {
return nil
}
return dictionary[key]
}
private static func readIORegistryBattery(
device: DevicePointer,
symbols: Symbols
) -> [String: Any]? {
let request: [String: Any] = [
"Request": "IORegistry",
"EntryClass": "AppleSmartBattery"
]
guard let response = sendServiceRequest(
service: "com.apple.mobile.diagnostics_relay",
request: request,
format: .xmlFormat_v1_0,
device: device,
symbols: symbols
),
response["Status"] as? String == "Success",
let diagnostics = response["Diagnostics"] as? [String: Any],
let registry = diagnostics["IORegistry"] as? [String: Any]
else {
return nil
}
return registry
}
private static func sendServiceRequest(
service: String,
request: [String: Any],
format: CFPropertyListFormat,
device: DevicePointer,
symbols: Symbols
) -> [String: Any]? {
var connection: DevicePointer?
guard symbols.startService(device, service as CFString, nil, &connection) == success,
let connection
else {
return nil
}
defer { _ = symbols.invalidateConnection(connection) }
configureTimeout(on: connection, symbols: symbols)
guard symbols.sendMessage(connection, request as CFDictionary, format) == success else {
return nil
}
var response: Unmanaged<CFTypeRef>?
var responseFormat = format
guard symbols.receiveMessage(connection, &response, &responseFormat) == success else {
return nil
}
return response?.takeRetainedValue() as? [String: Any]
}
private static func configureTimeout(on connection: DevicePointer, symbols: Symbols) {
let socket = symbols.connectionSocket(connection)
guard socket >= 0 else {
return
}
var timeout = timeval(tv_sec: serviceTimeoutSeconds, tv_usec: 0)
withUnsafePointer(to: &timeout) { pointer in
_ = setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size))
_ = setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size))
}
}
private static func copiedString(
_ key: String,
device: DevicePointer,
symbols: Symbols
) -> String {
symbols.copyValue(device, nil, key as CFString)?.takeRetainedValue() as? String ?? ""
}
private static func copiedDictionary(
domain: String?,
key: String?,
device: DevicePointer,
symbols: Symbols
) -> [String: Any]? {
symbols.copyValue(device, domain as CFString?, key as CFString?)?
.takeRetainedValue() as? [String: Any]
}
private static func connectionLabel(_ type: Int32) -> String {
switch type {
case 1:
return "USB"
case 2, 3:
return "Wi-Fi"
default:
return ""
}
}
private static func opaqueIdentifier(_ identifier: String) -> String {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in identifier.utf8 {
hash ^= UInt64(byte)
hash &*= 1_099_511_628_211
}
return String(hash, radix: 16)
}
private static func loadSymbols() -> Symbols? {
guard let handle = dlopen(frameworkPath, RTLD_NOW | RTLD_LOCAL) else {
return nil
}
func symbol<T>(_ name: String, as type: T.Type) -> T? {
guard let pointer = dlsym(handle, name) else {
return nil
}
return unsafeBitCast(pointer, to: T.self)
}
guard let createDeviceList = symbol("AMDCreateDeviceList", as: CreateDeviceListFunction.self),
let connect = symbol("AMDeviceConnect", as: DeviceResultFunction.self),
let disconnect = symbol("AMDeviceDisconnect", as: DeviceResultFunction.self),
let validatePairing = symbol("AMDeviceValidatePairing", as: DeviceResultFunction.self),
let startSession = symbol("AMDeviceStartSession", as: DeviceResultFunction.self),
let stopSession = symbol("AMDeviceStopSession", as: DeviceResultFunction.self),
let interfaceType = symbol("AMDeviceGetInterfaceType", as: DeviceResultFunction.self),
let copyDeviceIdentifier = symbol(
"AMDeviceCopyDeviceIdentifier",
as: CopyDeviceIdentifierFunction.self
),
let copyValue = symbol("AMDeviceCopyValue", as: CopyValueFunction.self),
let startService = symbol("AMDeviceSecureStartService", as: StartServiceFunction.self),
let sendMessage = symbol("AMDServiceConnectionSendMessage", as: SendMessageFunction.self),
let receiveMessage = symbol("AMDServiceConnectionReceiveMessage", as: ReceiveMessageFunction.self),
let invalidateConnection = symbol(
"AMDServiceConnectionInvalidate",
as: DeviceResultFunction.self
),
let connectionSocket = symbol(
"AMDServiceConnectionGetSocket",
as: DeviceResultFunction.self
)
else {
dlclose(handle)
return nil
}
return Symbols(
createDeviceList: createDeviceList,
connect: connect,
disconnect: disconnect,
validatePairing: validatePairing,
startSession: startSession,
stopSession: stopSession,
interfaceType: interfaceType,
copyDeviceIdentifier: copyDeviceIdentifier,
copyValue: copyValue,
startService: startService,
sendMessage: sendMessage,
receiveMessage: receiveMessage,
invalidateConnection: invalidateConnection,
connectionSocket: connectionSocket
)
}
}