import Foundation import IOBluetooth import IOKit import CoreAudio import AudioToolbox public final class BluetoothTracker: ObservableObject { public static let shared = BluetoothTracker() public static let didUpdateNotification = Notification.Name("BLTSTrackerDidUpdateNotification") @Published public var activeHeadphone: BluetoothDeviceModel? @Published public var devices: [BluetoothDeviceModel] = [] // Direct primitive properties for instant reactivity @Published public var currentBatteryPercent: Int? @Published public var currentDeviceName: String = "" @Published public var isConnected: Bool = false @Published public var isBluetoothPoweredOn: Bool = true private var timerSource: DispatchSourceTimer? private var knownBatteries: [String: Int] = [:] private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool public static func checkBluetoothPower() -> Bool { guard let controller = IOBluetoothHostController.default() else { return false } return controller.powerState == kBluetoothHCIPowerStateON } public init() { self.isBluetoothPoweredOn = BluetoothTracker.checkBluetoothPower() setupBluetoothListeners() startTracking() } deinit { stopTracking() } public func startTracking() { stopTracking() updateDeviceList() pollSubprocessProbe() // Strict 1.0s timer for probe monitoring (with zero-flicker authoritative updates) let timer = DispatchSource.makeTimerSource(flags: .strict, queue: DispatchQueue.main) timer.schedule(deadline: .now() + 1.0, repeating: 1.0, leeway: .milliseconds(50)) timer.setEventHandler { [weak self] in guard let self = self else { return } if !BluetoothTracker.checkBluetoothPower() { self.applyPowerOffState() } else { self.pollSubprocessProbe() } } timer.resume() self.timerSource = timer } public func stopTracking() { timerSource?.cancel() timerSource = nil } private var isProbeRunning = false private func resolveProbeBinary() -> (String, [String])? { // 1. Same directory as current executable if let exeDir = Bundle.main.executableURL?.deletingLastPathComponent().path { let dedicated = "\(exeDir)/battery_probe" if FileManager.default.isExecutableFile(atPath: dedicated) { return (dedicated, []) } } // 2. App bundle path in project directory let bundleProbe = "/Users/autli/Documents/blts_tracker/BLTS Tracker.app/Contents/MacOS/battery_probe" if FileManager.default.isExecutableFile(atPath: bundleProbe) { return (bundleProbe, []) } // 3. Current executable only if named blts_tracker if let exePath = Bundle.main.executablePath, exePath.hasSuffix("blts_tracker"), FileManager.default.isExecutableFile(atPath: exePath) { return (exePath, ["--battery-probe"]) } return nil } public func pollSubprocessProbe() { guard !isProbeRunning else { return } if !BluetoothTracker.checkBluetoothPower() { applyPowerOffState() return } guard let (binary, probeArgs) = resolveProbeBinary() else { return } isProbeRunning = true DispatchQueue.global(qos: .userInitiated).async { [weak self] in defer { self?.isProbeRunning = false } let proc = Process() proc.executableURL = URL(fileURLWithPath: binary) proc.arguments = probeArgs let pipe = Pipe() proc.standardOutput = pipe do { try proc.run() let data = pipe.fileHandleForReading.readDataToEndOfFile() proc.waitUntilExit() if let output = String(data: data, encoding: .utf8), !output.isEmpty { self?.parseProbeOutput(output) } } catch { // Ignore probe errors } } } private struct ProbeDev { let address: String let name: String let isConnected: Bool let singleBattery: Int? let leftBattery: Int? let rightBattery: Int? let caseBattery: Int? let combinedBattery: Int? } private func parseProbeOutput(_ output: String) { var powerOn: Bool = true var devMap: [String: ProbeDev] = [:] for line in output.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("POWER:") { let val = trimmed.replacingOccurrences(of: "POWER:", with: "") powerOn = (val == "1") } else if trimmed.hasPrefix("DEV:") { let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) if parts.count >= 8 { let addr = String(parts[1]) let name = String(parts[2]) let conn = parts[3] == "1" let single = Int(parts[4]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil } let left = Int(parts[5]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil } let right = Int(parts[6]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil } let bCase = Int(parts[7]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil } let comb = (parts.count > 8 ? Int(parts[8]) : nil).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil } devMap[addr] = ProbeDev( address: addr, name: name, isConnected: conn, singleBattery: single, leftBattery: left, rightBattery: right, caseBattery: bCase, combinedBattery: comb ) } } } let applyBlock = { [weak self] in guard let self = self else { return } if !powerOn { self.applyPowerOffState() } else { self.applyProbeDevices(devMap) } } if Thread.isMainThread { applyBlock() } else { DispatchQueue.main.async(execute: applyBlock) } } private func applyPowerOffState() { var changed = false if self.isBluetoothPoweredOn { self.isBluetoothPoweredOn = false changed = true } if self.isConnected { self.isConnected = false changed = true } if self.currentBatteryPercent != nil { self.currentBatteryPercent = nil changed = true } if !self.currentDeviceName.isEmpty { self.currentDeviceName = "" changed = true } if self.activeHeadphone != nil { self.activeHeadphone = nil changed = true } self.knownBatteries.removeAll() for i in 0.. 0 && fresh <= 100 { if devices[i].singleBattery != fresh { devices[i].singleBattery = fresh knownBatteries[addr] = fresh changed = true } } if let l = p.leftBattery, devices[i].leftBattery != l { devices[i].leftBattery = l changed = true } if let r = p.rightBattery, devices[i].rightBattery != r { devices[i].rightBattery = r changed = true } if let c = p.caseBattery, devices[i].caseBattery != c { devices[i].caseBattery = c changed = true } if let comb = p.combinedBattery, devices[i].combinedBattery != comb { devices[i].combinedBattery = comb changed = true } } else { knownBatteries.removeValue(forKey: addr) if devices[i].singleBattery != nil { devices[i].singleBattery = nil changed = true } } } } // Enrich connected devices from IOKit enrichFromIORegistry(devices: &devices) let foundActive = devices.first(where: { $0.isConnected && $0.isAudioDevice }) ?? devices.first(where: { $0.isConnected }) if activeHeadphone?.address != foundActive?.address { activeHeadphone = foundActive changed = true } let newPercent = foundActive?.primaryBatteryPercent if currentBatteryPercent != newPercent { currentBatteryPercent = newPercent changed = true } let newName = foundActive?.name ?? "" if currentDeviceName != newName { currentDeviceName = newName changed = true } let newConn = foundActive?.isConnected == true if isConnected != newConn { isConnected = newConn changed = true } if changed { objectWillChange.send() NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self) } } public func refreshNow() { if !BluetoothTracker.checkBluetoothPower() { applyPowerOffState() } else { updateDeviceList() pollSubprocessProbe() } } private func setupBluetoothListeners() { let notificationNames = [ "IOBluetoothDeviceWasConnectedNotification", "IOBluetoothDeviceWasDisconnectedNotification", "IOBluetoothDeviceNotification", "IOBluetoothDeviceServicesResolvedNotification", "IOBluetoothDeviceNameChangedNotification", "IOBluetoothHandsFreeDeviceDidUpdateNotification" ] for name in notificationNames { NotificationCenter.default.addObserver( self, selector: #selector(handleBluetoothChange), name: NSNotification.Name(name), object: nil ) } DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleBluetoothChange), name: NSNotification.Name("com.apple.Bluetooth.status"), object: nil ) } @objc private func handleBluetoothChange() { refreshNow() } public func updateDeviceList() { let powerOn = BluetoothTracker.checkBluetoothPower() self.isBluetoothPoweredOn = powerOn if !powerOn { applyPowerOffState() return } var updatedList: [BluetoothDeviceModel] = [] // 1. Scan IOBluetooth paired devices catalogue if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { for pairedDev in pairedDevices { let address = pairedDev.addressString ?? UUID().uuidString let name = pairedDev.nameOrAddress ?? "Unknown Device" let majorClass = UInt32(pairedDev.deviceClassMajor) let minorClass = UInt32(pairedDev.deviceClassMinor) let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass, majorClass: majorClass) var isMulti = false let selMulti = Selector(("isMultiBatteryDevice")) if pairedDev.responds(to: selMulti) { let imp = pairedDev.method(for: selMulti) let fn = unsafeBitCast(imp, to: BoolGetter.self) isMulti = fn(pairedDev, selMulti) } // IMPORTANT: Strictly preserve isConnected and battery from the authoritative probe! // NEVER query stale in-process IOBluetoothDevice ivars to avoid flickering! let existing = self.devices.first(where: { $0.address == address }) let isConn = existing?.isConnected ?? false var model = BluetoothDeviceModel( name: name, address: address, isConnected: isConn, isAudioDevice: isAudio, isMultiBattery: isMulti, majorClass: majorClass, minorClass: minorClass, lastUpdated: Date() ) if isConn { model.singleBattery = existing?.singleBattery model.leftBattery = existing?.leftBattery model.rightBattery = existing?.rightBattery model.caseBattery = existing?.caseBattery model.combinedBattery = existing?.combinedBattery } updatedList.append(model) } } // 2. Sort: Connected audio first updatedList.sort { d1, d2 in if d1.isConnected != d2.isConnected { return d1.isConnected && !d2.isConnected } if d1.isAudioDevice != d2.isAudioDevice { return d1.isAudioDevice && !d2.isAudioDevice } return d1.name.localizedCaseInsensitiveCompare(d2.name) == .orderedAscending } self.devices = updatedList let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice }) ?? updatedList.first(where: { $0.isConnected }) self.activeHeadphone = foundActive self.currentBatteryPercent = foundActive?.primaryBatteryPercent self.currentDeviceName = foundActive?.name ?? "" self.isConnected = (foundActive?.isConnected == true) self.objectWillChange.send() NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self) } public func getActiveCoreAudioDeviceName() -> String? { var defaultOutputDeviceID = AudioDeviceID(0) var propertySize = UInt32(MemoryLayout.size) var propertyAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize, &defaultOutputDeviceID ) guard status == noErr else { return nil } var nameSize = UInt32(MemoryLayout.size) var nameAddress = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceNameCFString, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var deviceName: CFString = "" as CFString let nameStatus = withUnsafeMutablePointer(to: &deviceName) { ptr in AudioObjectGetPropertyData( defaultOutputDeviceID, &nameAddress, 0, nil, &nameSize, ptr ) } if nameStatus == noErr { return deviceName as String } return nil } public func isLikelyHeadphones(name: String, minorClass: UInt32, majorClass: UInt32 = 0) -> Bool { let lower = name.lowercased() let nonAudioKeywords = ["mouse", "keyboard", "trackpad", "pen", "pencil", "watch"] for nonKw in nonAudioKeywords { if lower.contains(nonKw) { return false } } let keywords = ["buds", "pods", "freebuds", "airpods", "headphone", "headset", "earphone", "earbuds", "wh-", "wf-", "qc", "bose", "sony", "jbl", "beats", "marshall", "sennheiser", "soundcore", "pixel buds", "galaxy buds"] for kw in keywords { if lower.contains(kw) { return true } } if majorClass == 4 && (minorClass >= 1 && minorClass <= 6) { return true } return false } private func enrichFromIORegistry(devices: inout [BluetoothDeviceModel]) { let matching = IOServiceMatching("AppleDeviceManagementHIDEventService") var iterator: io_iterator_t = 0 guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else { return } defer { IOObjectRelease(iterator) } var entry = IOIteratorNext(iterator) while entry != 0 { var props: Unmanaged? if IORegistryEntryCreateCFProperties(entry, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS, let dict = props?.takeRetainedValue() as? [String: Any] { let prodName = dict["Product"] as? String ?? dict["DeviceName"] as? String let addr = dict["DeviceAddress"] as? String let batt = dict["BatteryPercent"] as? Int let battLeft = dict["BatteryPercentLeft"] as? Int let battRight = dict["BatteryPercentRight"] as? Int let battCase = dict["BatteryPercentCase"] as? Int for i in 0.. 0 && b <= 100 { devices[i].singleBattery = b knownBatteries[devices[i].address] = b } if devices[i].isMultiBattery { if let bl = battLeft, bl > 0 && bl <= 100 { devices[i].leftBattery = bl } if let br = battRight, br > 0 && br <= 100 { devices[i].rightBattery = br } if let bc = battCase, bc > 0 && bc <= 100 { devices[i].caseBattery = bc } } } } } IOObjectRelease(entry) entry = IOIteratorNext(iterator) } } }