diff --git a/.gitignore b/.gitignore index 9f9d8c6..69ce14e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.cache/ +check_devices +.test_runner diff --git a/Sources/blts_tracker/BLTSTrackerApp.swift b/Sources/blts_tracker/BLTSTrackerApp.swift index bc3b4ed..44f004e 100644 --- a/Sources/blts_tracker/BLTSTrackerApp.swift +++ b/Sources/blts_tracker/BLTSTrackerApp.swift @@ -1,71 +1,119 @@ -import SwiftUI +import Cocoa import IOBluetooth import IOKit import CoreAudio @main -struct BLTSTrackerApp: App { - @ObservedObject var tracker = BluetoothTracker.shared - @ObservedObject var updater = UpdaterService.shared - - var body: some Scene { - MenuBarExtra { - menuContent - } label: { - menuBarLabel +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private let tracker = BluetoothTracker.shared + private let updater = UpdaterService.shared + + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + 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 var menuBarLabel: some View { + private func updateStatusItem() { + guard let button = statusItem?.button else { return } + let active = tracker.activeHeadphone let isConn = active?.isConnected == true let percent = active?.primaryBatteryPercent - if isConn, let p = percent { - Label(" \(p)%", systemImage: "headphones") - .labelStyle(.titleAndIcon) + button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery") + + if isConn, let p = percent, p > 0 { + button.title = " \(p)%" } else { - Label("", systemImage: "headphones") - .labelStyle(.iconOnly) + button.title = "" } } - @ViewBuilder - private var menuContent: some View { - if let dev = tracker.activeHeadphone, dev.isConnected { + private func setupMenu() { + let menu = NSMenu() + + let active = tracker.activeHeadphone + if let dev = active, dev.isConnected { let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено" - Text("🎧 \(dev.name): \(percentStr)") - .font(.headline) + let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "") + titleItem.isEnabled = false + menu.addItem(titleItem) if let l = dev.leftBattery, let r = dev.rightBattery { - Text("Левый: \(l)% | Правый: \(r)%") - .font(.caption) - .foregroundColor(.secondary) + let subItem = NSMenuItem(title: " Левый: \(l)% | Правый: \(r)%", action: nil, keyEquivalent: "") + subItem.isEnabled = false + menu.addItem(subItem) } } else { - Text("🎧 Наушники не подключены") - .foregroundColor(.secondary) + let item = NSMenuItem(title: "🎧 Наушники не подключены", action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) } - Divider() + menu.addItem(NSMenuItem.separator()) - Button("Обновить заряд") { - tracker.refreshNow() - } - .keyboardShortcut("r") + let refreshItem = NSMenuItem(title: "🔄 Обновить заряд", action: #selector(refreshClicked), keyEquivalent: "r") + refreshItem.target = self + menu.addItem(refreshItem) - Button(updater.isUpdating ? "Обновление..." : "🔄 Обновить из GitHub") { - updater.checkForUpdatesAndApply() - } - .disabled(updater.isUpdating) - .keyboardShortcut("u") + 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) - Divider() + menu.addItem(NSMenuItem.separator()) - Button("Завершить") { - NSApplication.shared.terminate(nil) - } - .keyboardShortcut("q") + let quitItem = NSMenuItem(title: "Завершить", action: #selector(quitClicked), keyEquivalent: "q") + quitItem.target = self + menu.addItem(quitItem) + + statusItem.menu = menu + } + + @objc private func refreshClicked() { + tracker.refreshNow() + } + + @objc private func updateClicked() { + updater.checkForUpdatesAndApply() + } + + @objc private func quitClicked() { + NSApplication.shared.terminate(nil) } } diff --git a/Sources/blts_tracker/Services/BluetoothTracker.swift b/Sources/blts_tracker/Services/BluetoothTracker.swift index d95223b..48a1cfb 100644 --- a/Sources/blts_tracker/Services/BluetoothTracker.swift +++ b/Sources/blts_tracker/Services/BluetoothTracker.swift @@ -6,17 +6,23 @@ 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 + private var timer: Timer? private var lastValidBattery: [String: Int] = [:] private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool - private init() { + public init() { setupBluetoothListeners() startTracking() } @@ -29,8 +35,8 @@ public final class BluetoothTracker: ObservableObject { stopTracking() updateDeviceList() - // Use .common mode so timer runs continuously without pausing - let t = Timer(timeInterval: 1.5, repeats: true) { [weak self] _ in + // 1.0 second high-frequency poll in .common runloop mode + let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateDeviceList() } RunLoop.main.add(t, forMode: .common) @@ -65,7 +71,7 @@ public final class BluetoothTracker: ObservableObject { DispatchQueue.main.async { [weak self] in 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 self?.updateDeviceList() } @@ -91,7 +97,7 @@ public final class BluetoothTracker: ObservableObject { let majorClass = UInt32(device.deviceClassMajor) 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 let selMulti = Selector(("isMultiBatteryDevice")) @@ -137,11 +143,19 @@ public final class BluetoothTracker: ObservableObject { let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice }) ?? updatedList.first(where: { $0.isConnected }) + let newPercent = foundActive?.primaryBatteryPercent + let newName = foundActive?.name ?? "" + let newConnected = foundActive?.isConnected == true + let applyUpdate = { [weak self] in guard let self = self else { return } self.objectWillChange.send() self.devices = updatedList self.activeHeadphone = foundActive + self.currentBatteryPercent = newPercent + self.currentDeviceName = newName + self.isConnected = newConnected + NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self) } 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 propertySize = UInt32(MemoryLayout.size) var propertyAddress = AudioObjectPropertyAddress( @@ -194,13 +208,18 @@ public final class BluetoothTracker: ObservableObject { 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 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 minorClass >= 1 && minorClass <= 6 { + if majorClass == 4 && (minorClass >= 1 && minorClass <= 6) { return true } return false @@ -232,6 +251,7 @@ public final class BluetoothTracker: ObservableObject { ?? getBatteryValue(selectorName: "headsetBatteryPercent") ?? getBatteryValue(selectorName: "batteryLevel") ?? getBatteryValue(selectorName: "batteryPercentCombined") + ?? getBatteryValue(selectorName: "batteryPercentLeft") if let single = single { lastValidBattery[addr] = single @@ -269,17 +289,17 @@ public final class BluetoothTracker: ObservableObject { if matchByName || matchByAddr { 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 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 devices[i].rightBattery == nil { devices[i].rightBattery = br } + devices[i].rightBattery = br } if let bc = battCase, bc > 0 && bc <= 100 { - if devices[i].caseBattery == nil { devices[i].caseBattery = bc } + devices[i].caseBattery = bc } } } diff --git a/Tests/blts_trackerTests/BluetoothDeviceModelTests.swift b/Tests/blts_trackerTests/BluetoothDeviceModelTests.swift new file mode 100644 index 0000000..67e9fa5 --- /dev/null +++ b/Tests/blts_trackerTests/BluetoothDeviceModelTests.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct TestRunner { + public static var passed = 0 + public static var failed = 0 + + public static func assertEqual(_ 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") +} diff --git a/Tests/blts_trackerTests/BluetoothTrackerTests.swift b/Tests/blts_trackerTests/BluetoothTrackerTests.swift new file mode 100644 index 0000000..dbd57d9 --- /dev/null +++ b/Tests/blts_trackerTests/BluetoothTrackerTests.swift @@ -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) +} diff --git a/Tests/blts_trackerTests/main.swift b/Tests/blts_trackerTests/main.swift new file mode 100644 index 0000000..dd978ec --- /dev/null +++ b/Tests/blts_trackerTests/main.swift @@ -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) + } + } +} diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..972cbae --- /dev/null +++ b/run_tests.sh @@ -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"