Skip to content

Commit 61ff99e

Browse files
Fix: Implement device polling for late-connecting multitouch devices … (#109)
* Fix: Implement device polling for late-connecting multitouch devices and update UI notifications * Fix: Preserve polling backoff state during connection attempts and update related tests * Fix: Update resumeDevicePolling and pollForDevices methods for internal access and add corresponding unit tests * Fix: Implement attemptDeviceConnection method and add corresponding unit tests for connection logic
1 parent ee23ec0 commit 61ff99e

File tree

5 files changed

+807
-14
lines changed

5 files changed

+807
-14
lines changed

MiddleDrag/AppDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
7474
multitouchManager.start()
7575
if multitouchManager.isMonitoring {
7676
Log.info("Multitouch manager started", category: .app)
77+
} else if multitouchManager.isPollingForDevices {
78+
Log.info(
79+
"Multitouch manager polling for device connections (e.g., Bluetooth trackpad)",
80+
category: .device)
7781
} else {
7882
Log.warning(
7983
"Multitouch manager inactive: no compatible multitouch hardware detected.",

MiddleDrag/Core/MouseEventGenerator.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ final class MouseEventGenerator: @unchecked Sendable {
9393
guard shouldPostEvents else { return }
9494
let error = CGAssociateMouseAndMouseCursorPosition(1)
9595
if error != CGError.success {
96-
Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture)
96+
Log.warning("Failed to re-associate cursor: \(error.rawValue)", category: .gesture)
9797
}
9898
if let source = eventSource {
9999
source.localEventsSuppressionInterval = 0.25
@@ -146,7 +146,7 @@ final class MouseEventGenerator: @unchecked Sendable {
146146
if shouldPostEvents {
147147
let error = CGAssociateMouseAndMouseCursorPosition(0)
148148
if error != CGError.success {
149-
Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture)
149+
Log.warning("Failed to disassociate cursor: \(error.rawValue)", category: .gesture)
150150
}
151151
// Zero the suppression interval so our high-frequency synthetic events
152152
// don't suppress each other (default is 0.25s which eats events)
@@ -428,21 +428,21 @@ final class MouseEventGenerator: @unchecked Sendable {
428428
nonisolated(unsafe) private static var _displayReconfigToken: Bool = {
429429
// Register for display changes (resolution, arrangement, connect/disconnect).
430430
// The callback invalidates the cache so the next read picks up the new geometry.
431-
CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
431+
unsafe CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
432432
// Only invalidate after the reconfiguration completes
433433
if flags.contains(.beginConfigurationFlag) { return }
434434
MouseEventGenerator.displayBoundsLock.lock()
435-
MouseEventGenerator._cachedDisplayBounds = nil
435+
unsafe MouseEventGenerator._cachedDisplayBounds = nil
436436
MouseEventGenerator.displayBoundsLock.unlock()
437437
}, nil)
438438
return true
439439
}()
440440

441441
internal static var globalDisplayBounds: CGRect {
442-
_ = _displayReconfigToken // Ensure callback is registered
442+
_ = unsafe _displayReconfigToken // Ensure callback is registered
443443

444444
displayBoundsLock.lock()
445-
if let cached = _cachedDisplayBounds {
445+
if let cached = unsafe _cachedDisplayBounds {
446446
displayBoundsLock.unlock()
447447
return cached
448448
}
@@ -451,7 +451,7 @@ final class MouseEventGenerator: @unchecked Sendable {
451451
// Compute outside lock (CGGetOnlineDisplayList is thread-safe)
452452
var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16)
453453
var displayCount: UInt32 = 0
454-
CGGetOnlineDisplayList(16, &displayIDs, &displayCount)
454+
unsafe CGGetOnlineDisplayList(16, &displayIDs, &displayCount)
455455

456456
var union = CGRect.null
457457
for i in 0..<Int(displayCount) {
@@ -460,7 +460,7 @@ final class MouseEventGenerator: @unchecked Sendable {
460460
let result = union == .null ? CGRect(x: 0, y: 0, width: 1920, height: 1080) : union
461461

462462
displayBoundsLock.lock()
463-
_cachedDisplayBounds = result
463+
unsafe _cachedDisplayBounds = result
464464
displayBoundsLock.unlock()
465465

466466
return result

MiddleDrag/Managers/MultitouchManager.swift

Lines changed: 211 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ public final class MultitouchManager: @unchecked Sendable {
2424
/// race conditions in the MultitouchSupport framework's internal thread.
2525
static let minimumRestartInterval: TimeInterval = 0.6
2626

27+
/// Initial interval between polling attempts when no multitouch device is found at launch.
28+
/// This handles Bluetooth trackpads that connect after login (common during boot).
29+
/// 3 seconds is a good balance between responsiveness and low overhead.
30+
static let devicePollingInterval: TimeInterval = 3.0
31+
32+
/// Maximum interval between polling attempts after exponential backoff.
33+
/// Caps at 30 seconds to avoid excessive resource usage while still checking.
34+
static let maxDevicePollingInterval: TimeInterval = 30.0
35+
36+
/// Maximum total polling duration before giving up (5 minutes).
37+
/// If no device connects within this window, polling stops and the user
38+
/// can manually re-enable via the menu bar.
39+
static let maxPollingDuration: TimeInterval = 300.0
40+
2741
// MARK: - Properties
2842

2943
/// Current gesture configuration
@@ -95,6 +109,19 @@ public final class MultitouchManager: @unchecked Sendable {
95109
private var isRestartInProgress = false
96110
private var lastRestartCompletedTime: TimeInterval = 0
97111

112+
// Device polling for late-connecting devices (e.g., Bluetooth trackpads at login)
113+
private var devicePollingTimer: DispatchSourceTimer?
114+
/// Current polling interval — increases with exponential backoff.
115+
/// Internal access for testability.
116+
internal var currentPollingInterval: TimeInterval = 0
117+
/// When polling started — used to enforce maxPollingDuration timeout.
118+
/// Internal access for testability.
119+
internal var pollingStartTime: TimeInterval = 0
120+
/// Whether we are actively polling for multitouch device connections.
121+
/// This is true when start() was called but no devices were found, so we're
122+
/// periodically checking for devices that may connect later (e.g., Bluetooth trackpad at boot).
123+
public private(set) var isPollingForDevices = false
124+
98125
// Processing queue
99126
private let gestureQueue = DispatchQueue(label: "com.middledrag.gesture", qos: .userInteractive)
100127

@@ -149,7 +176,7 @@ public final class MultitouchManager: @unchecked Sendable {
149176

150177
/// Start monitoring for gestures
151178
public func start() {
152-
guard !isMonitoring else { return }
179+
guard !isMonitoring && !isPollingForDevices else { return }
153180

154181
applyConfiguration()
155182
let eventTapSuccess = eventTapSetupFactory()
@@ -164,13 +191,18 @@ public final class MultitouchManager: @unchecked Sendable {
164191

165192
guard deviceMonitor?.start() == true else {
166193
Log.warning(
167-
"No compatible multitouch hardware detected. Gesture monitoring disabled.",
194+
"No compatible multitouch hardware detected. Will poll for device connections.",
168195
category: .device)
169196
deviceMonitor?.stop()
170197
deviceMonitor = nil
171198
teardownEventTap()
172199
isMonitoring = false
173200
isEnabled = false
201+
202+
// Start polling for late-connecting devices (e.g., Bluetooth trackpad at boot).
203+
// Also register wake observers so polling resumes after sleep.
204+
addSleepWakeObservers()
205+
startDevicePolling()
174206
return
175207
}
176208

@@ -182,6 +214,9 @@ public final class MultitouchManager: @unchecked Sendable {
182214

183215
/// Stop monitoring
184216
public func stop() {
217+
// Stop device polling if active
218+
stopDevicePolling()
219+
185220
// Clear restart state and cancel any pending restart work item.
186221
// This must be done under lock to prevent data races with restart().
187222
restartLock.lock()
@@ -204,12 +239,15 @@ public final class MultitouchManager: @unchecked Sendable {
204239
/// Restart monitoring (used after sleep/wake)
205240
public func restart() {
206241
// Allow restart if either:
207-
// 1. wakeObserver exists (normal production case after successful start)
242+
// 1. wakeObserver exists (normal production case after successful start, or polling state)
208243
// 2. isMonitoring is true (for test scenarios where event tap setup may fail)
209244
// Using wakeObserver allows retry after failed restart (when isMonitoring=false)
210245
// because internalStop() sets isMonitoring=false before setupEventTap() runs
211246
guard wakeObserver != nil || isMonitoring else { return }
212247

248+
// Stop device polling if active — restart will re-evaluate device availability
249+
stopDevicePolling()
250+
213251
// Prevent concurrent restart operations - this is critical to avoid race conditions
214252
// when rapid foreground/background toggling triggers multiple restart() calls.
215253
// The MultitouchSupport framework's internal thread can crash (EXC_BREAKPOINT) if
@@ -301,14 +339,15 @@ public final class MultitouchManager: @unchecked Sendable {
301339

302340
guard deviceMonitor?.start() == true else {
303341
Log.warning(
304-
"Restart aborted: no compatible multitouch hardware detected.",
342+
"Restart: no compatible multitouch hardware detected. Will poll for device connections.",
305343
category: .device)
306344
deviceMonitor?.stop()
307345
deviceMonitor = nil
308346
teardownEventTap()
309347
isMonitoring = false
310348
isEnabled = false
311-
removeSleepWakeObservers()
349+
// Start polling instead of giving up — device may reconnect shortly after wake
350+
startDevicePolling()
312351
markRestartComplete()
313352
return
314353
}
@@ -327,6 +366,157 @@ public final class MultitouchManager: @unchecked Sendable {
327366
restartLock.unlock()
328367
}
329368

369+
// MARK: - Device Polling
370+
371+
/// Start polling for multitouch device connections.
372+
/// Called when start() or performRestart() finds no devices — typically during boot
373+
/// when a Bluetooth Magic Trackpad hasn't connected yet.
374+
private func startDevicePolling() {
375+
guard !isPollingForDevices else { return }
376+
377+
isPollingForDevices = true
378+
currentPollingInterval = Self.devicePollingInterval
379+
pollingStartTime = CACurrentMediaTime()
380+
Log.info(
381+
"Starting device polling (initial interval: \(Self.devicePollingInterval)s, max duration: \(Self.maxPollingDuration)s)",
382+
category: .device)
383+
384+
scheduleNextPoll()
385+
}
386+
387+
/// Stop device polling.
388+
/// Called when devices are found, when stop() is called, or when restart() begins.
389+
private func stopDevicePolling() {
390+
guard isPollingForDevices else { return }
391+
392+
cancelPollingTimer()
393+
isPollingForDevices = false
394+
currentPollingInterval = 0
395+
pollingStartTime = 0
396+
Log.debug("Device polling stopped", category: .device)
397+
}
398+
399+
/// Cancel the polling timer without resetting backoff state.
400+
/// Used when pausing polling during a connection attempt so that if it fails,
401+
/// resumeDevicePolling() can continue with the correct interval and elapsed time.
402+
private func cancelPollingTimer() {
403+
devicePollingTimer?.cancel()
404+
devicePollingTimer = nil
405+
}
406+
407+
/// Schedule the next poll with exponential backoff.
408+
private func scheduleNextPoll() {
409+
devicePollingTimer?.cancel()
410+
411+
let timer = DispatchSource.makeTimerSource(queue: .main)
412+
timer.schedule(deadline: .now() + currentPollingInterval)
413+
timer.setEventHandler { [weak self] in
414+
self?.pollForDevices()
415+
}
416+
timer.resume()
417+
devicePollingTimer = timer
418+
}
419+
420+
/// Resume polling after a failed connection attempt, preserving backoff state.
421+
/// Unlike startDevicePolling(), this doesn't reset the interval or start time.
422+
/// Internal access for testability.
423+
internal func resumeDevicePolling() {
424+
isPollingForDevices = true
425+
currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval)
426+
Log.debug(
427+
unsafe "Resuming device polling (next in \(String(format: "%.0f", currentPollingInterval))s)",
428+
category: .device)
429+
scheduleNextPoll()
430+
}
431+
432+
/// Check if any multitouch devices are now available.
433+
/// Called periodically by the polling timer with exponential backoff.
434+
/// Internal access for testability.
435+
internal func pollForDevices() {
436+
guard isPollingForDevices else {
437+
stopDevicePolling()
438+
return
439+
}
440+
441+
// Check if we've exceeded the maximum polling duration
442+
let elapsed = CACurrentMediaTime() - pollingStartTime
443+
if elapsed >= Self.maxPollingDuration {
444+
Log.info(
445+
"Device polling timed out after \(Int(elapsed))s — no multitouch device found. "
446+
+ "User can re-enable from menu bar.",
447+
category: .device)
448+
stopDevicePolling()
449+
// Notify UI so it can show the timed-out state
450+
NotificationCenter.default.post(name: .middleDragPollingTimedOut, object: nil)
451+
return
452+
}
453+
454+
// Quick check using the framework's device list
455+
guard let deviceList = MTDeviceCreateList(),
456+
CFArrayGetCount(deviceList) > 0
457+
else {
458+
Log.debug(
459+
unsafe "Device poll: no multitouch devices found yet (next in \(String(format: "%.0f", currentPollingInterval))s)",
460+
category: .device)
461+
// Exponential backoff: double the interval, capped at max
462+
currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval)
463+
scheduleNextPoll()
464+
return
465+
}
466+
467+
Log.info(
468+
"Device poll: multitouch device(s) detected, attempting connection...",
469+
category: .device)
470+
// Pause the timer but preserve backoff state — if connection fails,
471+
// resumeDevicePolling() needs the current interval and start time intact.
472+
cancelPollingTimer()
473+
474+
attemptDeviceConnection()
475+
}
476+
477+
/// Attempt to connect to a detected multitouch device.
478+
/// Called by pollForDevices() after MTDeviceCreateList confirms a device exists.
479+
/// On failure, resumes polling with backoff. On success, transitions to monitoring.
480+
/// Internal access for testability — allows tests to exercise connection logic
481+
/// without depending on real hardware via MTDeviceCreateList.
482+
internal func attemptDeviceConnection() {
483+
applyConfiguration()
484+
let eventTapSuccess = eventTapSetupFactory()
485+
486+
guard eventTapSuccess else {
487+
Log.error("Device poll: could not create event tap", category: .device)
488+
// Resume polling — event tap failure may be transient.
489+
// Use resumeDevicePolling to preserve backoff state.
490+
resumeDevicePolling()
491+
return
492+
}
493+
494+
deviceMonitor = deviceProviderFactory()
495+
unsafe deviceMonitor?.delegate = self
496+
497+
guard deviceMonitor?.start() == true else {
498+
Log.warning(
499+
"Device poll: device detected but could not start monitoring, resuming polling",
500+
category: .device)
501+
deviceMonitor?.stop()
502+
deviceMonitor = nil
503+
teardownEventTap()
504+
resumeDevicePolling()
505+
return
506+
}
507+
508+
// Success! Monitoring is now active.
509+
isMonitoring = true
510+
isEnabled = true
511+
isPollingForDevices = false
512+
currentPollingInterval = 0
513+
pollingStartTime = 0
514+
Log.info("Multitouch monitoring started after device connection", category: .device)
515+
516+
// Notify UI so menu bar icon updates from disabled → enabled
517+
NotificationCenter.default.post(name: .middleDragDeviceConnected, object: nil)
518+
}
519+
330520
/// Internal stop without removing sleep/wake observers
331521
private func internalStop() {
332522
mouseGenerator.cancelDrag()
@@ -384,6 +574,22 @@ public final class MultitouchManager: @unchecked Sendable {
384574

385575
/// Toggle enabled state
386576
func toggleEnabled() {
577+
// If we're polling for devices, treat toggle as "stop trying"
578+
if isPollingForDevices {
579+
stopDevicePolling()
580+
removeSleepWakeObservers()
581+
isEnabled = false
582+
return
583+
}
584+
585+
// If user is trying to enable while not monitoring,
586+
// attempt to start monitoring. This handles the case where the app launched
587+
// before a Bluetooth trackpad connected and the user manually tries to enable.
588+
if !isEnabled && !isMonitoring {
589+
start()
590+
return
591+
}
592+
387593
isEnabled.toggle()
388594

389595
if !isEnabled {

0 commit comments

Comments
 (0)