Skip to content

Bug/bluetooth#102

Closed
NullPointerDepressiveDisorder wants to merge 5 commits intomainfrom
bug/bluetooth
Closed

Bug/bluetooth#102
NullPointerDepressiveDisorder wants to merge 5 commits intomainfrom
bug/bluetooth

Conversation

@NullPointerDepressiveDisorder
Copy link
Owner

This pull request introduces robust support for detecting multitouch HID device (e.g., Bluetooth Magic Trackpad) connections and disconnections after app launch, ensuring MiddleDrag can dynamically respond to hardware changes—especially when devices connect after login. It achieves this by adding a new HIDDeviceWatcher class, integrating it into MultitouchManager, updating UI in response to device state changes, and providing comprehensive unit tests for the new watcher.

Device connection detection and management:

  • Introduced the new HIDDeviceWatcher class, which uses IOKit to monitor for multitouch HID device connections/disconnections. It handles debouncing, ignores initial enumeration events, and notifies its delegate of device state changes.
  • Integrated HIDDeviceWatcher into MultitouchManager, starting and stopping the watcher as needed. When a new device is detected, the manager attempts to start or restart monitoring, ensuring the app responds to devices connected after launch. Also implemented the delegate methods for connection/disconnection events. [1] [2] [3] [4]
  • Updated AppDelegate to listen for .deviceConnectionStateChanged notifications and refresh the menu bar UI when the device state changes, providing immediate feedback to the user. [1] [2]

Testing and project setup:

  • Added a comprehensive test suite for HIDDeviceWatcher in HIDDeviceWatcherTests.swift, covering lifecycle, delegate wiring, debouncing constants, and memory management.
  • Updated the Xcode project (project.pbxproj) to include the new test file and ensure proper test target membership and folder exception handling. [1] [2] [3]

@sentry
Copy link
Contributor

sentry bot commented Feb 8, 2026

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds runtime detection of multitouch HID device connect/disconnect events (e.g., Bluetooth Magic Trackpad after login) so MiddleDrag can start/restart monitoring and refresh the menu bar UI when hardware availability changes.

Changes:

  • Introduces HIDDeviceWatcher (IOKit/IOHIDManager-based) to watch digitizer-class device arrivals/removals with debouncing.
  • Integrates the watcher into MultitouchManager and posts a new .deviceConnectionStateChanged notification for UI refresh.
  • Adds unit tests for the watcher and for MultitouchManager’s HID watcher integration; updates the Xcode project to include the new tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
MiddleDrag/Managers/HIDDeviceWatcher.swift New IOHIDManager-based device watcher with debouncing and initial-enumeration suppression.
MiddleDrag/Managers/MultitouchManager.swift Starts/stops watcher, reacts to connect/disconnect, and posts notification on successful late-start.
MiddleDrag/AppDelegate.swift Observes .deviceConnectionStateChanged and refreshes the menu bar UI.
MiddleDrag/UI/MenuBarController.swift Adds the new notification name constant.
MiddleDrag/MiddleDragTests/HIDDeviceWatcherTests.swift New tests for watcher lifecycle/delegate/debounce constant assumptions.
MiddleDrag/MiddleDragTests/MultitouchManagerHIDTests.swift New integration-style tests simulating watcher callbacks to validate late-start/restart behavior.
MiddleDrag.xcodeproj/project.pbxproj Registers the new test files and adjusts test-target membership exceptions.
Comments suppressed due to low confidence (1)

MiddleDrag/Managers/MultitouchManager.swift:158

  • Marking start() as @MainActor is a breaking change for synchronous callers: existing call sites (including unit tests) that invoke start() from non-@MainActor contexts will become compile errors unless updated to @MainActor/await MainActor.run. Either update all call sites accordingly, or avoid actor-isolating this synchronous API (e.g., keep it nonisolated and internally dispatch to the main actor/queue).
    @MainActor func start() {
        guard !isMonitoring else { return }

        applyConfiguration()
        let eventTapSuccess = eventTapSetupFactory()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +119 to +122

isRunning = true
startTime = ProcessInfo.processInfo.systemUptime
Log.info("HID device watcher started", category: .device)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTime is set after IOHIDManagerOpen, but IOHIDManager can deliver initial matching callbacks immediately after scheduling/opening. If a callback arrives before startTime is assigned, elapsed will be computed from the default 0 and the initial-enumeration suppression won’t work (causing restarts on launch). Set startTime before registering/opening the manager (or initialize it at the top of start()) so early callbacks are properly ignored.

Copilot uses AI. Check for mistakes.

/// Stop monitoring
func stop() {
@MainActor func stop() {
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marking stop() as @MainActor is a breaking change for synchronous callers: existing call sites (including unit tests) that invoke stop() from non-@MainActor contexts will become compile errors unless updated to @MainActor/await MainActor.run. Either update all call sites accordingly, or avoid actor-isolating this synchronous API (e.g., keep it nonisolated and internally dispatch to the main actor/queue).

Suggested change
@MainActor func stop() {
func stop() {

Copilot uses AI. Check for mistakes.
Comment on lines +1034 to +1038
// If the removed device was the only one, DeviceMonitor.start() will
// fail and we'll gracefully go back to waiting for a new device.
if isMonitoring {
restart()
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On HID device removal, this code triggers restart() but never posts .deviceConnectionStateChanged. If the removed device was the last one, restart() can transition the manager to disabled/not monitoring, but the menu bar UI won’t refresh because notifications are only posted in the “fresh start after connection” path. Consider posting .deviceConnectionStateChanged on disconnections as well (or centralize state-change notifications when isEnabled/isMonitoring changes due to device availability).

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +18
final class MultitouchManagerHIDTests: XCTestCase {

// MARK: - Initial Start Failure Path

func testStartWithNoDevicesDoesNotMonitor() {
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case calls MultitouchManager.start() / stop(), which are now @MainActor. Unless the test runs in a @MainActor context, these calls will be compile errors. Consider annotating the test class as @MainActor (as done in MenuBarControllerTests) or wrapping calls in await MainActor.run { … }.

Copilot uses AI. Check for mistakes.
addSleepWakeObservers()

isMonitoring = true
isEnabled = true
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attemptStartAfterDeviceConnection() unconditionally sets isEnabled = true on a successful late start. This can override an explicit user “Disabled” choice made while no compatible hardware was present (since toggleEnabled() can be used even when isMonitoring == false). Consider preserving the previous enabled state (similar to how restart() preserves wasEnabled) instead of forcing enabled to true on device connection.

Suggested change
isEnabled = true

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +118
let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
if result != kIOReturnSuccess {
Log.warning("Failed to open IOHIDManager: \(result)", category: .device)
return
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIDDeviceWatcher.start() returns early when IOHIDManagerOpen fails, but leaves hidManager scheduled on the main run loop with callbacks registered while isRunning remains false. Because stop() guards on isRunning, a later stop()/deinit will not unschedule/close the manager, which can leak resources and potentially allow callbacks to fire after the watcher is deallocated. Consider cleaning up (unschedule + close + nil out hidManager) on the failure path and/or making stop() clean up whenever hidManager is non-nil (even if isRunning is false).

Copilot uses AI. Check for mistakes.
…otations and update Swift version to 6.0
/// Unlike restart(), this handles the case where monitoring was never
/// successfully started (e.g., no devices at launch).
private func attemptStartAfterDeviceConnection() {
if isMonitoring {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A race condition between restart() and attemptStartAfterDeviceConnection() can cause resource leaks due to missing lock acquisition when modifying shared state like deviceMonitor.
Severity: MEDIUM

Suggested Fix

Ensure that attemptStartAfterDeviceConnection() acquires the restartLock before reading or modifying shared state variables like isMonitoring and deviceMonitor. This will prevent it from interleaving with an in-progress restart() operation, ensuring state consistency and preventing resource leaks.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: MiddleDrag/Managers/MultitouchManager.swift#L415

Potential issue: A race condition can occur between `restart()` (triggered by system
wake) and `attemptStartAfterDeviceConnection()` (triggered by a new device). Both
operate on the main queue but can interleave. `attemptStartAfterDeviceConnection()`
reads and modifies shared state like `isMonitoring` and `deviceMonitor` without
acquiring the `restartLock` that `restart()` uses. This can lead to an inconsistent
state where a `deviceMonitor` instance is created by
`attemptStartAfterDeviceConnection()` only to be overwritten by `performRestart()`
moments later, causing the first monitor's resources to leak.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment on lines +152 to +154
Log.debug("HID device watcher ignoring initial enumeration: \(productName)", category: .device)
return
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A new device connecting within 2 seconds of app launch will be ignored, as the code incorrectly assumes it's part of the initial device enumeration.
Severity: MEDIUM

Suggested Fix

Instead of a fixed time window, a more robust approach would be to track the devices reported during the initial enumeration phase from IOHIDManager. Once the initial enumeration is complete, any subsequent connection callback can be treated as a genuinely new device. This avoids incorrectly ignoring legitimate, fast-connecting devices post-launch.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: MiddleDrag/Managers/HIDDeviceWatcher.swift#L152-L154

Potential issue: The `HIDDeviceWatcher` uses a fixed 2-second window after startup to
ignore device connection events, assuming they are from the initial enumeration of
already-connected devices. However, a genuinely new Bluetooth device, such as a Magic
Trackpad being woken up, can connect within this 2-second window immediately after the
app launches at login. When this happens, the connection is incorrectly discarded as an
'initial enumeration' event, and the `MultitouchManager` is never notified. The
application will fail to detect the new device until the app is restarted.

Did we get this right? 👍 / 👎 to inform future reviews.

@NullPointerDepressiveDisorder
Copy link
Owner Author

Will refactor fixes on a fresh branch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MiddleDrag starts disabled after every Mac reboot (likely because I am using a Bluetooth Magic Trackpad)

2 participants