fix: use DispatchSourceTimer and fresh IOBluetooth handle to guarantee real-time property refresh

This commit is contained in:
Nikita 2026-10-06 13:10:21 +05:00
parent 7710a300fc
commit ec302446d9
2 changed files with 39 additions and 27 deletions

View file

@ -42,6 +42,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
object: nil object: nil
) )
// Start active tracking
tracker.startTracking()
// Initial render // Initial render
updateStatusItem() updateStatusItem()
} }

View file

@ -16,7 +16,7 @@ public final class BluetoothTracker: ObservableObject {
@Published public var currentDeviceName: String = "" @Published public var currentDeviceName: String = ""
@Published public var isConnected: Bool = false @Published public var isConnected: Bool = false
private var timer: Timer? private var timerSource: DispatchSourceTimer?
private var lastValidBattery: [String: Int] = [:] private var lastValidBattery: [String: Int] = [:]
private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int
@ -35,17 +35,19 @@ public final class BluetoothTracker: ObservableObject {
stopTracking() stopTracking()
updateDeviceList() updateDeviceList()
// 1.0 second high-frequency poll in .common runloop mode // Kernel-level DispatchSourceTimer: Immune to RunLoop pauses or UI tracking modes
let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in 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
self?.updateDeviceList() self?.updateDeviceList()
} }
RunLoop.main.add(t, forMode: .common) timer.resume()
self.timer = t self.timerSource = timer
} }
public func stopTracking() { public func stopTracking() {
timer?.invalidate() timerSource?.cancel()
timer = nil timerSource = nil
} }
public func refreshNow() { public func refreshNow() {
@ -84,16 +86,18 @@ public final class BluetoothTracker: ObservableObject {
// 1. Scan IOBluetooth paired devices // 1. Scan IOBluetooth paired devices
if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] {
for device in pairedDevices { for pairedDev in pairedDevices {
let name = device.nameOrAddress ?? "Unknown Device" let address = pairedDev.addressString ?? UUID().uuidString
let address = device.addressString ?? UUID().uuidString // Re-instantiate device to bypass any internal property caching
let device = IOBluetoothDevice(addressString: address) ?? pairedDev
let name = device.nameOrAddress ?? pairedDev.nameOrAddress ?? "Unknown Device"
let isAudioOutput = activeAudioName != nil && ( let isAudioOutput = activeAudioName != nil && (
name.caseInsensitiveCompare(activeAudioName!) == .orderedSame || name.caseInsensitiveCompare(activeAudioName!) == .orderedSame ||
activeAudioName!.localizedCaseInsensitiveContains(name) || activeAudioName!.localizedCaseInsensitiveContains(name) ||
name.localizedCaseInsensitiveContains(activeAudioName!) name.localizedCaseInsensitiveContains(activeAudioName!)
) )
let isConnected = device.isConnected() || isAudioOutput let isConnected = device.isConnected() || pairedDev.isConnected() || isAudioOutput
let majorClass = UInt32(device.deviceClassMajor) let majorClass = UInt32(device.deviceClassMajor)
let minorClass = UInt32(device.deviceClassMinor) let minorClass = UInt32(device.deviceClassMinor)
@ -119,7 +123,7 @@ public final class BluetoothTracker: ObservableObject {
) )
if isConnected { if isConnected {
extractBatteryLevels(from: device, into: &model, isMulti: isMulti) extractBatteryLevels(from: device, fallbackDevice: pairedDev, into: &model, isMulti: isMulti)
} }
updatedList.append(model) updatedList.append(model)
@ -225,33 +229,38 @@ public final class BluetoothTracker: ObservableObject {
return false return false
} }
private func extractBatteryLevels(from device: IOBluetoothDevice, into model: inout BluetoothDeviceModel, isMulti: Bool) { private func extractBatteryLevels(from device: IOBluetoothDevice, fallbackDevice: IOBluetoothDevice? = nil, into model: inout BluetoothDeviceModel, isMulti: Bool) {
func getBatteryValue(selectorName: String) -> Int? { func getBatteryValue(from dev: IOBluetoothDevice, selectorName: String) -> Int? {
let sel = Selector((selectorName)) let sel = Selector((selectorName))
guard device.responds(to: sel) else { return nil } guard dev.responds(to: sel) else { return nil }
let imp = device.method(for: sel) let imp = dev.method(for: sel)
let fn = unsafeBitCast(imp, to: IntBatteryGetter.self) let fn = unsafeBitCast(imp, to: IntBatteryGetter.self)
let val = fn(device, sel) let val = fn(dev, sel)
if val > 0 && val <= 100 { if val > 0 && val <= 100 {
return val return val
} }
return nil return nil
} }
func query(selectorName: String) -> Int? {
return getBatteryValue(from: device, selectorName: selectorName)
?? (fallbackDevice != nil ? getBatteryValue(from: fallbackDevice!, selectorName: selectorName) : nil)
}
let addr = model.address let addr = model.address
if isMulti { if isMulti {
model.leftBattery = getBatteryValue(selectorName: "batteryPercentLeft") model.leftBattery = query(selectorName: "batteryPercentLeft")
model.rightBattery = getBatteryValue(selectorName: "batteryPercentRight") model.rightBattery = query(selectorName: "batteryPercentRight")
model.caseBattery = getBatteryValue(selectorName: "batteryPercentCase") model.caseBattery = query(selectorName: "batteryPercentCase")
model.combinedBattery = getBatteryValue(selectorName: "batteryPercentCombined") model.combinedBattery = query(selectorName: "batteryPercentCombined")
model.singleBattery = getBatteryValue(selectorName: "batteryPercentSingle") model.singleBattery = query(selectorName: "batteryPercentSingle")
} else { } else {
let single = getBatteryValue(selectorName: "batteryPercentSingle") let single = query(selectorName: "batteryPercentSingle")
?? getBatteryValue(selectorName: "headsetBatteryPercent") ?? query(selectorName: "headsetBatteryPercent")
?? getBatteryValue(selectorName: "batteryLevel") ?? query(selectorName: "batteryLevel")
?? getBatteryValue(selectorName: "batteryPercentCombined") ?? query(selectorName: "batteryPercentCombined")
?? getBatteryValue(selectorName: "batteryPercentLeft") ?? query(selectorName: "batteryPercentLeft")
if let single = single { if let single = single {
lastValidBattery[addr] = single lastValidBattery[addr] = single