feat: direct native NSStatusItem real-time battery display and comprehensive automated test suite

This commit is contained in:
Nikita 2026-10-06 12:45:58 +05:00
parent 353e1da2fe
commit a938dc196f
7 changed files with 306 additions and 54 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ DerivedData/
.swiftpm/configuration/registries.json .swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc .netrc
.cache/
check_devices
.test_runner

View file

@ -1,71 +1,119 @@
import SwiftUI import Cocoa
import IOBluetooth import IOBluetooth
import IOKit import IOKit
import CoreAudio import CoreAudio
@main @main
struct BLTSTrackerApp: App { final class AppDelegate: NSObject, NSApplicationDelegate {
@ObservedObject var tracker = BluetoothTracker.shared private var statusItem: NSStatusItem!
@ObservedObject var updater = UpdaterService.shared private let tracker = BluetoothTracker.shared
private let updater = UpdaterService.shared
var body: some Scene { static func main() {
MenuBarExtra { let app = NSApplication.shared
menuContent let delegate = AppDelegate()
} label: { app.delegate = delegate
menuBarLabel app.setActivationPolicy(.accessory)
app.run()
}
func applicationDidFinishLaunching(_ notification: Notification) {
// Create native status bar item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery")
button.imagePosition = .imageLeft
}
setupMenu()
// Listen to real-time tracker updates
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTrackerUpdate),
name: BluetoothTracker.didUpdateNotification,
object: nil
)
// Initial render
updateStatusItem()
}
@objc private func handleTrackerUpdate() {
DispatchQueue.main.async { [weak self] in
self?.updateStatusItem()
self?.setupMenu()
} }
} }
@ViewBuilder private func updateStatusItem() {
private var menuBarLabel: some View { guard let button = statusItem?.button else { return }
let active = tracker.activeHeadphone let active = tracker.activeHeadphone
let isConn = active?.isConnected == true let isConn = active?.isConnected == true
let percent = active?.primaryBatteryPercent let percent = active?.primaryBatteryPercent
if isConn, let p = percent { button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery")
Label(" \(p)%", systemImage: "headphones")
.labelStyle(.titleAndIcon) if isConn, let p = percent, p > 0 {
button.title = " \(p)%"
} else { } else {
Label("", systemImage: "headphones") button.title = ""
.labelStyle(.iconOnly)
} }
} }
@ViewBuilder private func setupMenu() {
private var menuContent: some View { let menu = NSMenu()
if let dev = tracker.activeHeadphone, dev.isConnected {
let active = tracker.activeHeadphone
if let dev = active, dev.isConnected {
let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено" let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено"
Text("🎧 \(dev.name): \(percentStr)") let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "")
.font(.headline) titleItem.isEnabled = false
menu.addItem(titleItem)
if let l = dev.leftBattery, let r = dev.rightBattery { if let l = dev.leftBattery, let r = dev.rightBattery {
Text("Левый: \(l)% | Правый: \(r)%") let subItem = NSMenuItem(title: " Левый: \(l)% | Правый: \(r)%", action: nil, keyEquivalent: "")
.font(.caption) subItem.isEnabled = false
.foregroundColor(.secondary) menu.addItem(subItem)
} }
} else { } else {
Text("🎧 Наушники не подключены") let item = NSMenuItem(title: "🎧 Наушники не подключены", action: nil, keyEquivalent: "")
.foregroundColor(.secondary) item.isEnabled = false
menu.addItem(item)
} }
Divider() menu.addItem(NSMenuItem.separator())
Button("Обновить заряд") { let refreshItem = NSMenuItem(title: "🔄 Обновить заряд", action: #selector(refreshClicked), keyEquivalent: "r")
refreshItem.target = self
menu.addItem(refreshItem)
let updateTitle = updater.isUpdating ? "⏳ Обновление..." : "🌐 Обновить из GitHub"
let updateItem = NSMenuItem(title: updateTitle, action: #selector(updateClicked), keyEquivalent: "u")
updateItem.target = self
if updater.isUpdating { updateItem.isEnabled = false }
menu.addItem(updateItem)
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "Завершить", action: #selector(quitClicked), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
}
@objc private func refreshClicked() {
tracker.refreshNow() tracker.refreshNow()
} }
.keyboardShortcut("r")
Button(updater.isUpdating ? "Обновление..." : "🔄 Обновить из GitHub") { @objc private func updateClicked() {
updater.checkForUpdatesAndApply() updater.checkForUpdatesAndApply()
} }
.disabled(updater.isUpdating)
.keyboardShortcut("u")
Divider() @objc private func quitClicked() {
Button("Завершить") {
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
} }
.keyboardShortcut("q")
}
} }

View file

@ -6,17 +6,23 @@ import AudioToolbox
public final class BluetoothTracker: ObservableObject { public final class BluetoothTracker: ObservableObject {
public static let shared = BluetoothTracker() public static let shared = BluetoothTracker()
public static let didUpdateNotification = Notification.Name("BLTSTrackerDidUpdateNotification")
@Published public var activeHeadphone: BluetoothDeviceModel? @Published public var activeHeadphone: BluetoothDeviceModel?
@Published public var devices: [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
private var timer: Timer? private var timer: Timer?
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
private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool
private init() { public init() {
setupBluetoothListeners() setupBluetoothListeners()
startTracking() startTracking()
} }
@ -29,8 +35,8 @@ public final class BluetoothTracker: ObservableObject {
stopTracking() stopTracking()
updateDeviceList() updateDeviceList()
// Use .common mode so timer runs continuously without pausing // 1.0 second high-frequency poll in .common runloop mode
let t = Timer(timeInterval: 1.5, repeats: true) { [weak self] _ in let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateDeviceList() self?.updateDeviceList()
} }
RunLoop.main.add(t, forMode: .common) RunLoop.main.add(t, forMode: .common)
@ -65,7 +71,7 @@ public final class BluetoothTracker: ObservableObject {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.updateDeviceList() self?.updateDeviceList()
} }
for delay in [0.2, 0.6, 1.2, 2.0] { for delay in [0.2, 0.5, 1.0, 1.5, 2.0] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.updateDeviceList() self?.updateDeviceList()
} }
@ -91,7 +97,7 @@ public final class BluetoothTracker: ObservableObject {
let majorClass = UInt32(device.deviceClassMajor) let majorClass = UInt32(device.deviceClassMajor)
let minorClass = UInt32(device.deviceClassMinor) let minorClass = UInt32(device.deviceClassMinor)
let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass) || isAudioOutput let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass, majorClass: majorClass) || isAudioOutput
var isMulti = false var isMulti = false
let selMulti = Selector(("isMultiBatteryDevice")) let selMulti = Selector(("isMultiBatteryDevice"))
@ -137,11 +143,19 @@ public final class BluetoothTracker: ObservableObject {
let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice }) let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice })
?? updatedList.first(where: { $0.isConnected }) ?? updatedList.first(where: { $0.isConnected })
let newPercent = foundActive?.primaryBatteryPercent
let newName = foundActive?.name ?? ""
let newConnected = foundActive?.isConnected == true
let applyUpdate = { [weak self] in let applyUpdate = { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.objectWillChange.send() self.objectWillChange.send()
self.devices = updatedList self.devices = updatedList
self.activeHeadphone = foundActive self.activeHeadphone = foundActive
self.currentBatteryPercent = newPercent
self.currentDeviceName = newName
self.isConnected = newConnected
NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self)
} }
if Thread.isMainThread { if Thread.isMainThread {
@ -151,7 +165,7 @@ public final class BluetoothTracker: ObservableObject {
} }
} }
private func getActiveCoreAudioDeviceName() -> String? { public func getActiveCoreAudioDeviceName() -> String? {
var defaultOutputDeviceID = AudioDeviceID(0) var defaultOutputDeviceID = AudioDeviceID(0)
var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size) var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
var propertyAddress = AudioObjectPropertyAddress( var propertyAddress = AudioObjectPropertyAddress(
@ -194,13 +208,18 @@ public final class BluetoothTracker: ObservableObject {
return nil return nil
} }
private func isLikelyHeadphones(name: String, minorClass: UInt32) -> Bool { public func isLikelyHeadphones(name: String, minorClass: UInt32, majorClass: UInt32 = 0) -> Bool {
let lower = name.lowercased() 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"] 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 { for kw in keywords {
if lower.contains(kw) { return true } if lower.contains(kw) { return true }
} }
if minorClass >= 1 && minorClass <= 6 { if majorClass == 4 && (minorClass >= 1 && minorClass <= 6) {
return true return true
} }
return false return false
@ -232,6 +251,7 @@ public final class BluetoothTracker: ObservableObject {
?? getBatteryValue(selectorName: "headsetBatteryPercent") ?? getBatteryValue(selectorName: "headsetBatteryPercent")
?? getBatteryValue(selectorName: "batteryLevel") ?? getBatteryValue(selectorName: "batteryLevel")
?? getBatteryValue(selectorName: "batteryPercentCombined") ?? getBatteryValue(selectorName: "batteryPercentCombined")
?? getBatteryValue(selectorName: "batteryPercentLeft")
if let single = single { if let single = single {
lastValidBattery[addr] = single lastValidBattery[addr] = single
@ -269,17 +289,17 @@ public final class BluetoothTracker: ObservableObject {
if matchByName || matchByAddr { if matchByName || matchByAddr {
if let b = batt, b > 0 && b <= 100 { if let b = batt, b > 0 && b <= 100 {
if devices[i].singleBattery == nil { devices[i].singleBattery = b } devices[i].singleBattery = b
} }
if devices[i].isMultiBattery { if devices[i].isMultiBattery {
if let bl = battLeft, bl > 0 && bl <= 100 { if let bl = battLeft, bl > 0 && bl <= 100 {
if devices[i].leftBattery == nil { devices[i].leftBattery = bl } devices[i].leftBattery = bl
} }
if let br = battRight, br > 0 && br <= 100 { if let br = battRight, br > 0 && br <= 100 {
if devices[i].rightBattery == nil { devices[i].rightBattery = br } devices[i].rightBattery = br
} }
if let bc = battCase, bc > 0 && bc <= 100 { if let bc = battCase, bc > 0 && bc <= 100 {
if devices[i].caseBattery == nil { devices[i].caseBattery = bc } devices[i].caseBattery = bc
} }
} }
} }

View file

@ -0,0 +1,75 @@
import Foundation
public struct TestRunner {
public static var passed = 0
public static var failed = 0
public static func assertEqual<T: Equatable>(_ actual: T, _ expected: T, _ message: String = "", file: String = #file, line: Int = #line) {
if actual == expected {
passed += 1
print(" ✅ [PASS] \(message)")
} else {
failed += 1
print(" ❌ [FAIL] \(message) -> Expected: \(expected), Got: \(actual) at line \(line)")
}
}
public static func assertTrue(_ condition: Bool, _ message: String = "", file: String = #file, line: Int = #line) {
assertEqual(condition, true, message, file: file, line: line)
}
}
func runDeviceModelTests() {
print("\n--- Running BluetoothDeviceModel Tests ---")
// 1. Single Battery Device (like Huawei FreeBuds 4i)
var singleDevice = BluetoothDeviceModel(
name: "HUAWEI FreeBuds 4i",
address: "60-aa-ef-5c-85-07",
isConnected: true,
isAudioDevice: true,
isMultiBattery: false,
singleBattery: 40
)
TestRunner.assertEqual(singleDevice.primaryBatteryPercent, 40, "Single battery device primaryBatteryPercent == 40")
TestRunner.assertTrue(singleDevice.hasBatteryData, "Single battery device hasBatteryData is true")
// 2. Battery percentage changes from 40 to 30 (Real-time drain simulation)
singleDevice.singleBattery = 30
TestRunner.assertEqual(singleDevice.primaryBatteryPercent, 30, "Single battery device updates to 30 on drain")
// 3. Multi Battery Device (Left + Right AirPods / FreeBuds Pro)
let multiDevice = BluetoothDeviceModel(
name: "AirPods Pro",
address: "11-22-33-44-55-66",
isConnected: true,
isAudioDevice: true,
isMultiBattery: true,
leftBattery: 85,
rightBattery: 90,
caseBattery: 100
)
TestRunner.assertEqual(multiDevice.primaryBatteryPercent, 85, "Multi battery device picks min(left, right) -> 85")
// 4. Multi Battery with single earbud in ear
let singleEarbud = BluetoothDeviceModel(
name: "AirPods Pro",
address: "11-22-33-44-55-66",
isConnected: true,
isAudioDevice: true,
isMultiBattery: true,
leftBattery: nil,
rightBattery: 75
)
TestRunner.assertEqual(singleEarbud.primaryBatteryPercent, 75, "Multi battery with only one earbud connected returns 75")
// 5. Zero / Invalid battery handling (disconnected or 0% bug)
let disconnectedDevice = BluetoothDeviceModel(
name: "Unknown Headset",
address: "00-00-00-00-00-00",
isConnected: false,
isAudioDevice: true,
singleBattery: 0
)
TestRunner.assertEqual(disconnectedDevice.primaryBatteryPercent, nil, "0% battery is treated as no data / invalid")
}

View file

@ -0,0 +1,50 @@
import Foundation
import IOBluetooth
import IOKit
import CoreAudio
func runBluetoothTrackerTests() {
print("\n--- Running BluetoothTracker Integration Tests ---")
let tracker = BluetoothTracker.shared
// 1. Check device recognition
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "HUAWEI FreeBuds 4i", minorClass: 1), "Recognizes HUAWEI FreeBuds 4i as headphones")
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "AirPods Pro", minorClass: 1), "Recognizes AirPods Pro as headphones")
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "Sony WH-1000XM4", minorClass: 1), "Recognizes Sony WH-1000XM4 as headphones")
TestRunner.assertEqual(tracker.isLikelyHeadphones(name: "Apple Mouse", minorClass: 3), false, "Rejects Apple Mouse as headphones")
// 2. Scan and verify live device reading
tracker.updateDeviceList()
print(" [Live Scan Results]")
for dev in tracker.devices {
print(" Device: \(dev.name) | Connected: \(dev.isConnected) | Battery: \(dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "nil")")
}
if let active = tracker.activeHeadphone {
print(" Active Headphone detected: \(active.name) (Battery: \(active.primaryBatteryPercent != nil ? "\(active.primaryBatteryPercent!)%" : "nil"))")
TestRunner.assertTrue(active.isConnected, "Active headphone is connected")
TestRunner.assertEqual(active.name, "HUAWEI FreeBuds 4i", "Active headphone is HUAWEI FreeBuds 4i")
TestRunner.assertTrue(active.primaryBatteryPercent != nil, "Active headphone has valid battery percent")
if let p = active.primaryBatteryPercent {
TestRunner.assertTrue(p > 0 && p <= 100, "Active headphone battery percent (\(p)%) is between 1 and 100")
}
} else {
print(" No active headphones currently connected.")
}
// 3. Notification emission test
var notificationReceived = false
let observer = NotificationCenter.default.addObserver(
forName: BluetoothTracker.didUpdateNotification,
object: nil,
queue: .main
) { _ in
notificationReceived = true
}
tracker.updateDeviceList()
TestRunner.assertTrue(notificationReceived, "BluetoothTracker emits didUpdateNotification on refresh")
NotificationCenter.default.removeObserver(observer)
}

View file

@ -0,0 +1,27 @@
import Foundation
@main
struct TestMain {
static func main() {
print("========================================")
print("🚀 RUNNING BLTS TRACKER TEST SUITE")
print("========================================")
runDeviceModelTests()
runBluetoothTrackerTests()
print("\n========================================")
print("🏁 TEST SUMMARY")
print("========================================")
print("Passed: \(TestRunner.passed)")
print("Failed: \(TestRunner.failed)")
if TestRunner.failed > 0 {
print("❌ Some tests failed!")
exit(1)
} else {
print("🎉 ALL TESTS PASSED SUCCESSFULLY!")
exit(0)
}
}
}

29
run_tests.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
MODULE_CACHE="$DIR/.module-cache"
TEST_BIN="$DIR/.test_runner"
echo "🧪 Compiling and executing BLTS Tracker test suite..."
mkdir -p "$MODULE_CACHE"
swiftc -parse-as-library \
-module-cache-path "$MODULE_CACHE" \
-O \
-framework Foundation \
-framework Cocoa \
-framework IOBluetooth \
-framework IOKit \
-framework CoreAudio \
-framework AudioToolbox \
-framework UserNotifications \
"$DIR/Sources/blts_tracker/Models/BluetoothDeviceModel.swift" \
"$DIR/Sources/blts_tracker/Services/BluetoothTracker.swift" \
"$DIR/Tests/blts_trackerTests/BluetoothDeviceModelTests.swift" \
"$DIR/Tests/blts_trackerTests/BluetoothTrackerTests.swift" \
"$DIR/Tests/blts_trackerTests/main.swift" \
-o "$TEST_BIN"
"$TEST_BIN"
rm -f "$TEST_BIN"