@@ -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