fix: use DispatchSourceTimer and fresh IOBluetooth handle to guarantee real-time property refresh
This commit is contained in:
parent
7710a300fc
commit
ec302446d9
2 changed files with 39 additions and 27 deletions
|
|
@ -42,6 +42,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Start active tracking
|
||||||
|
tracker.startTracking()
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
updateStatusItem()
|
updateStatusItem()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue