From 31fab983ffe9a5701350af3d0fed27d9a742951e Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:53:04 -0800 Subject: [PATCH 1/4] Fix: Implement device polling for late-connecting multitouch devices and update UI notifications --- MiddleDrag/AppDelegate.swift | 4 + MiddleDrag/Core/MouseEventGenerator.swift | 16 +- MiddleDrag/Managers/MultitouchManager.swift | 190 +++++++++++++++++- .../MultitouchManagerTests.swift | 108 ++++++++++ MiddleDrag/UI/MenuBarController.swift | 41 +++- 5 files changed, 345 insertions(+), 14 deletions(-) diff --git a/MiddleDrag/AppDelegate.swift b/MiddleDrag/AppDelegate.swift index 4a489f8..f0e485d 100644 --- a/MiddleDrag/AppDelegate.swift +++ b/MiddleDrag/AppDelegate.swift @@ -74,6 +74,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { multitouchManager.start() if multitouchManager.isMonitoring { Log.info("Multitouch manager started", category: .app) + } else if multitouchManager.isPollingForDevices { + Log.info( + "Multitouch manager polling for device connections (e.g., Bluetooth trackpad)", + category: .device) } else { Log.warning( "Multitouch manager inactive: no compatible multitouch hardware detected.", diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 91eb4a8..0006fed 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -93,7 +93,7 @@ final class MouseEventGenerator: @unchecked Sendable { guard shouldPostEvents else { return } let error = CGAssociateMouseAndMouseCursorPosition(1) if error != CGError.success { - Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture) + Log.warning( "Failed to re-associate cursor: \(error.rawValue)", category: .gesture) } if let source = eventSource { source.localEventsSuppressionInterval = 0.25 @@ -146,7 +146,7 @@ final class MouseEventGenerator: @unchecked Sendable { if shouldPostEvents { let error = CGAssociateMouseAndMouseCursorPosition(0) if error != CGError.success { - Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture) + Log.warning( "Failed to disassociate cursor: \(error.rawValue)", category: .gesture) } // Zero the suppression interval so our high-frequency synthetic events // don't suppress each other (default is 0.25s which eats events) @@ -428,21 +428,21 @@ final class MouseEventGenerator: @unchecked Sendable { nonisolated(unsafe) private static var _displayReconfigToken: Bool = { // Register for display changes (resolution, arrangement, connect/disconnect). // The callback invalidates the cache so the next read picks up the new geometry. - CGDisplayRegisterReconfigurationCallback({ _, flags, _ in + unsafe CGDisplayRegisterReconfigurationCallback({ _, flags, _ in // Only invalidate after the reconfiguration completes if flags.contains(.beginConfigurationFlag) { return } MouseEventGenerator.displayBoundsLock.lock() - MouseEventGenerator._cachedDisplayBounds = nil + unsafe MouseEventGenerator._cachedDisplayBounds = nil MouseEventGenerator.displayBoundsLock.unlock() }, nil) return true }() internal static var globalDisplayBounds: CGRect { - _ = _displayReconfigToken // Ensure callback is registered + _ = unsafe _displayReconfigToken // Ensure callback is registered displayBoundsLock.lock() - if let cached = _cachedDisplayBounds { + if let cached = unsafe _cachedDisplayBounds { displayBoundsLock.unlock() return cached } @@ -451,7 +451,7 @@ final class MouseEventGenerator: @unchecked Sendable { // Compute outside lock (CGGetOnlineDisplayList is thread-safe) var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16) var displayCount: UInt32 = 0 - CGGetOnlineDisplayList(16, &displayIDs, &displayCount) + unsafe CGGetOnlineDisplayList(16, &displayIDs, &displayCount) var union = CGRect.null for i in 0..= Self.maxPollingDuration { + Log.info( + "Device polling timed out after \(Int(elapsed))s — no multitouch device found. " + + "User can re-enable from menu bar.", + category: .device) + stopDevicePolling() + // Notify UI so it can show the timed-out state + NotificationCenter.default.post(name: .middleDragPollingTimedOut, object: nil) + return + } + + // Quick check using the framework's device list + guard let deviceList = MTDeviceCreateList(), + CFArrayGetCount(deviceList) > 0 + else { + Log.debug( + unsafe "Device poll: no multitouch devices found yet (next in \(String(format: "%.0f", currentPollingInterval))s)", + category: .device) + // Exponential backoff: double the interval, capped at max + currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval) + scheduleNextPoll() + return + } + + Log.info( + "Device poll: multitouch device(s) detected, attempting connection...", + category: .device) + stopDevicePolling() + + // Attempt full connection + applyConfiguration() + let eventTapSuccess = eventTapSetupFactory() + + guard eventTapSuccess else { + Log.error("Device poll: could not create event tap", category: .device) + // Resume polling — event tap failure may be transient. + // Use resumeDevicePolling to preserve backoff state. + resumeDevicePolling() + return + } + + deviceMonitor = deviceProviderFactory() + unsafe deviceMonitor?.delegate = self + + guard deviceMonitor?.start() == true else { + Log.warning( + "Device poll: device detected but could not start monitoring, resuming polling", + category: .device) + deviceMonitor?.stop() + deviceMonitor = nil + teardownEventTap() + resumeDevicePolling() + return + } + + // Success! Monitoring is now active. + isMonitoring = true + isEnabled = true + Log.info("Multitouch monitoring started after device connection", category: .device) + + // Notify UI so menu bar icon updates from disabled → enabled + NotificationCenter.default.post(name: .middleDragDeviceConnected, object: nil) + } + /// Internal stop without removing sleep/wake observers private func internalStop() { mouseGenerator.cancelDrag() @@ -384,6 +548,22 @@ public final class MultitouchManager: @unchecked Sendable { /// Toggle enabled state func toggleEnabled() { + // If we're polling for devices, treat toggle as "stop trying" + if isPollingForDevices { + stopDevicePolling() + removeSleepWakeObservers() + isEnabled = false + return + } + + // If user is trying to enable while not monitoring, + // attempt to start monitoring. This handles the case where the app launched + // before a Bluetooth trackpad connected and the user manually tries to enable. + if !isEnabled && !isMonitoring { + start() + return + } + isEnabled.toggle() if !isEnabled { diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 18d258e..390e13a 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -183,6 +183,10 @@ final class MultitouchManagerTests: XCTestCase { XCTAssertFalse(manager.isMonitoring) XCTAssertFalse(manager.isEnabled) + // When no hardware is found, manager should begin polling for late-connecting devices + XCTAssertTrue(manager.isPollingForDevices) + + manager.stop() // Clean up polling timer } func testRestartStopsWhenHardwareUnavailable() { @@ -212,6 +216,10 @@ final class MultitouchManagerTests: XCTestCase { XCTAssertFalse(manager.isMonitoring) XCTAssertFalse(manager.isEnabled) + // When restart fails to find hardware, manager should poll for device connections + XCTAssertTrue(manager.isPollingForDevices) + + manager.stop() // Clean up polling timer } func testStopSetsMonitoringToFalse() { @@ -338,6 +346,106 @@ final class MultitouchManagerTests: XCTestCase { XCTAssertFalse(manager.isEnabled, "Manager should be disabled") } + // MARK: - Device Polling Tests + + func testStartBeginsPollingWhenNoDeviceFound() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + + XCTAssertFalse(manager.isMonitoring) + XCTAssertTrue(manager.isPollingForDevices, "Should poll when no device at launch") + + manager.stop() + } + + func testStopCancelsDevicePolling() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + manager.stop() + XCTAssertFalse(manager.isPollingForDevices, "Polling should stop on explicit stop()") + } + + func testDoubleStartDoesNotDoublePoll() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + // Second start should be a no-op since we're already polling + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + manager.stop() + } + + func testToggleEnabledAttemptsStartWhenNotMonitoring() { + var shouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { + let monitor = unsafe MockDeviceMonitor() + unsafe monitor.startShouldSucceed = shouldSucceed + return unsafe monitor + }, + eventTapSetup: { true } + ) + + // Initial start fails → starts polling + manager.start() + XCTAssertFalse(manager.isMonitoring) + XCTAssertTrue(manager.isPollingForDevices) + manager.stop() + XCTAssertFalse(manager.isPollingForDevices) + + // Now make device available and toggle enabled + shouldSucceed = true + manager.toggleEnabled() + + XCTAssertTrue(manager.isMonitoring, "toggleEnabled should start monitoring if device now available") + XCTAssertTrue(manager.isEnabled) + + manager.stop() + } + + func testToggleEnabledWhilePollingStopsPolling() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + // Toggling while polling should stop polling (user says "stop trying") + manager.toggleEnabled() + XCTAssertFalse(manager.isPollingForDevices, "Toggle while polling should stop polling") + XCTAssertFalse(manager.isEnabled) + XCTAssertFalse(manager.isMonitoring) + } + + func testPollingConstants() { + // Verify backoff and timeout constants are sensible + XCTAssertEqual(MultitouchManager.devicePollingInterval, 3.0) + XCTAssertEqual(MultitouchManager.maxDevicePollingInterval, 30.0) + XCTAssertEqual(MultitouchManager.maxPollingDuration, 300.0) + XCTAssertGreaterThan( + MultitouchManager.maxDevicePollingInterval, + MultitouchManager.devicePollingInterval, + "Max interval must be greater than initial interval for backoff to work") + } + // MARK: - GestureRecognizerDelegate State Transition Tests func testGestureRecognizerDidStartSetsThreeFingerGestureState() { diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 8e2f168..189dea5 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -34,6 +34,22 @@ public class MenuBarController: NSObject { super.init() setupStatusItem() + + // Listen for late device connections (e.g., Bluetooth trackpad connecting after boot) + NotificationCenter.default.addObserver( + self, + selector: #selector(deviceDidConnect), + name: .middleDragDeviceConnected, + object: nil + ) + + // Listen for polling timeout (no device found within time limit) + NotificationCenter.default.addObserver( + self, + selector: #selector(pollingDidTimeout), + name: .middleDragPollingTimedOut, + object: nil + ) } deinit { @@ -120,7 +136,15 @@ public class MenuBarController: NSObject { private func createStatusItem() -> NSMenuItem { let isEnabled = multitouchManager?.isEnabled ?? false - let title = isEnabled ? "MiddleDrag Active" : "MiddleDrag Disabled" + let isPolling = multitouchManager?.isPollingForDevices ?? false + let title: String + if isPolling { + title = "Waiting for Trackpad…" + } else if isEnabled { + title = "MiddleDrag Active" + } else { + title = "MiddleDrag Disabled" + } let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = false return item @@ -435,6 +459,17 @@ public class MenuBarController: NSObject { // MARK: - Actions + @objc func deviceDidConnect() { + let isEnabled = multitouchManager?.isEnabled ?? false + updateStatusIcon(enabled: isEnabled) + buildMenu() + } + + @objc func pollingDidTimeout() { + updateStatusIcon(enabled: false) + buildMenu() + } + @objc func toggleEnabled() { multitouchManager?.toggleEnabled() let isEnabled = multitouchManager?.isEnabled ?? false @@ -730,5 +765,9 @@ public class MenuBarController: NSObject { extension Notification.Name { public static let preferencesChanged = Notification.Name("MiddleDragPreferencesChanged") public static let launchAtLoginChanged = Notification.Name("MiddleDragLaunchAtLoginChanged") + /// Posted when a multitouch device connects after polling (e.g., Bluetooth trackpad at boot) + public static let middleDragDeviceConnected = Notification.Name("MiddleDragDeviceConnected") + /// Posted when device polling times out without finding a device + public static let middleDragPollingTimedOut = Notification.Name("MiddleDragPollingTimedOut") } From 18714b7e0cf4ce3ed2f2fb461a94514e5e5d109a Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:41:17 -0800 Subject: [PATCH 2/4] Fix: Preserve polling backoff state during connection attempts and update related tests --- MiddleDrag/Core/MouseEventGenerator.swift | 4 +- MiddleDrag/Managers/MultitouchManager.swift | 26 ++++++++-- .../MultitouchManagerTests.swift | 49 +++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 0006fed..8bf8104 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -93,7 +93,7 @@ final class MouseEventGenerator: @unchecked Sendable { guard shouldPostEvents else { return } let error = CGAssociateMouseAndMouseCursorPosition(1) if error != CGError.success { - Log.warning( "Failed to re-associate cursor: \(error.rawValue)", category: .gesture) + Log.warning("Failed to re-associate cursor: \(error.rawValue)", category: .gesture) } if let source = eventSource { source.localEventsSuppressionInterval = 0.25 @@ -146,7 +146,7 @@ final class MouseEventGenerator: @unchecked Sendable { if shouldPostEvents { let error = CGAssociateMouseAndMouseCursorPosition(0) if error != CGError.success { - Log.warning( "Failed to disassociate cursor: \(error.rawValue)", category: .gesture) + Log.warning("Failed to disassociate cursor: \(error.rawValue)", category: .gesture) } // Zero the suppression interval so our high-frequency synthetic events // don't suppress each other (default is 0.25s which eats events) diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 8c298ea..2216111 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -111,8 +111,12 @@ public final class MultitouchManager: @unchecked Sendable { // Device polling for late-connecting devices (e.g., Bluetooth trackpads at login) private var devicePollingTimer: DispatchSourceTimer? - private var currentPollingInterval: TimeInterval = 0 - private var pollingStartTime: TimeInterval = 0 + /// Current polling interval — increases with exponential backoff. + /// Internal access for testability. + internal var currentPollingInterval: TimeInterval = 0 + /// When polling started — used to enforce maxPollingDuration timeout. + /// Internal access for testability. + internal var pollingStartTime: TimeInterval = 0 /// Whether we are actively polling for multitouch device connections. /// This is true when start() was called but no devices were found, so we're /// periodically checking for devices that may connect later (e.g., Bluetooth trackpad at boot). @@ -385,14 +389,21 @@ public final class MultitouchManager: @unchecked Sendable { private func stopDevicePolling() { guard isPollingForDevices else { return } - devicePollingTimer?.cancel() - devicePollingTimer = nil + cancelPollingTimer() isPollingForDevices = false currentPollingInterval = 0 pollingStartTime = 0 Log.debug("Device polling stopped", category: .device) } + /// Cancel the polling timer without resetting backoff state. + /// Used when pausing polling during a connection attempt so that if it fails, + /// resumeDevicePolling() can continue with the correct interval and elapsed time. + private func cancelPollingTimer() { + devicePollingTimer?.cancel() + devicePollingTimer = nil + } + /// Schedule the next poll with exponential backoff. private func scheduleNextPoll() { devicePollingTimer?.cancel() @@ -454,7 +465,9 @@ public final class MultitouchManager: @unchecked Sendable { Log.info( "Device poll: multitouch device(s) detected, attempting connection...", category: .device) - stopDevicePolling() + // Pause the timer but preserve backoff state — if connection fails, + // resumeDevicePolling() needs the current interval and start time intact. + cancelPollingTimer() // Attempt full connection applyConfiguration() @@ -485,6 +498,9 @@ public final class MultitouchManager: @unchecked Sendable { // Success! Monitoring is now active. isMonitoring = true isEnabled = true + isPollingForDevices = false + currentPollingInterval = 0 + pollingStartTime = 0 Log.info("Multitouch monitoring started after device connection", category: .device) // Notify UI so menu bar icon updates from disabled → enabled diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 390e13a..1f30224 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -446,6 +446,55 @@ final class MultitouchManagerTests: XCTestCase { "Max interval must be greater than initial interval for backoff to work") } + func testPollingBackoffStatePreservedAcrossConnectionAttempt() { + // Validates fix for: stopDevicePolling() was resetting currentPollingInterval + // and pollingStartTime before connection attempts in pollForDevices(). + // If the connection failed and resumeDevicePolling() ran, the interval would + // become 0 (0 * 2 = 0) and elapsed time would trigger immediate timeout. + // + // After the fix, pollForDevices() uses cancelPollingTimer() which only cancels + // the timer without resetting backoff state. + + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + // Verify initial backoff state is set correctly + XCTAssertEqual( + manager.currentPollingInterval, + MultitouchManager.devicePollingInterval, + "Initial polling interval should match devicePollingInterval constant") + XCTAssertGreaterThan( + manager.pollingStartTime, 0, + "Polling start time should be recorded") + + let originalStartTime = manager.pollingStartTime + + // Simulate what happens during a failed connection attempt: + // stopDevicePolling() would have zeroed these, but cancelPollingTimer() should not. + // stop() calls stopDevicePolling() which does reset — verify that's the only path. + manager.stop() + XCTAssertEqual(manager.currentPollingInterval, 0, "stop() should reset interval") + XCTAssertEqual(manager.pollingStartTime, 0, "stop() should reset start time") + + // Restart polling and verify state is freshly initialized (not stale) + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + XCTAssertEqual( + manager.currentPollingInterval, + MultitouchManager.devicePollingInterval, + "Restarted polling should have fresh initial interval") + XCTAssertGreaterThanOrEqual( + manager.pollingStartTime, originalStartTime, + "Restarted polling should have new start time") + + manager.stop() + } + // MARK: - GestureRecognizerDelegate State Transition Tests func testGestureRecognizerDidStartSetsThreeFingerGestureState() { From 65bb4e9675ac1347805ab2a10aad2ef479eaf05a Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:04:12 -0800 Subject: [PATCH 3/4] Fix: Update resumeDevicePolling and pollForDevices methods for internal access and add corresponding unit tests --- MiddleDrag/Managers/MultitouchManager.swift | 6 +- .../MultitouchManagerTests.swift | 375 ++++++++++++++++++ 2 files changed, 379 insertions(+), 2 deletions(-) diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 2216111..61b9a92 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -419,7 +419,8 @@ public final class MultitouchManager: @unchecked Sendable { /// Resume polling after a failed connection attempt, preserving backoff state. /// Unlike startDevicePolling(), this doesn't reset the interval or start time. - private func resumeDevicePolling() { + /// Internal access for testability. + internal func resumeDevicePolling() { isPollingForDevices = true currentPollingInterval = min(currentPollingInterval * 2, Self.maxDevicePollingInterval) Log.debug( @@ -430,7 +431,8 @@ public final class MultitouchManager: @unchecked Sendable { /// Check if any multitouch devices are now available. /// Called periodically by the polling timer with exponential backoff. - private func pollForDevices() { + /// Internal access for testability. + internal func pollForDevices() { guard isPollingForDevices else { stopDevicePolling() return diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 1f30224..7d60064 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -495,6 +495,381 @@ final class MultitouchManagerTests: XCTestCase { manager.stop() } + // MARK: - resumeDevicePolling Tests + + func testResumeDevicePollingDoublesInterval() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + let initialInterval = manager.currentPollingInterval + XCTAssertEqual(initialInterval, MultitouchManager.devicePollingInterval) + + // Simulate a failed connection attempt: pause polling, then resume + manager.resumeDevicePolling() + + XCTAssertEqual( + manager.currentPollingInterval, + min(initialInterval * 2, MultitouchManager.maxDevicePollingInterval), + "resumeDevicePolling should double the interval") + XCTAssertTrue(manager.isPollingForDevices) + + manager.stop() + } + + func testResumeDevicePollingCapsAtMaxInterval() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + // Set interval just below max to verify cap + manager.currentPollingInterval = MultitouchManager.maxDevicePollingInterval - 1.0 + + manager.resumeDevicePolling() + + XCTAssertEqual( + manager.currentPollingInterval, + MultitouchManager.maxDevicePollingInterval, + "Interval should be capped at maxDevicePollingInterval") + + manager.stop() + } + + func testResumeDevicePollingAlreadyAtMaxStaysCapped() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + + // Set interval at max + manager.currentPollingInterval = MultitouchManager.maxDevicePollingInterval + + manager.resumeDevicePolling() + + XCTAssertEqual( + manager.currentPollingInterval, + MultitouchManager.maxDevicePollingInterval, + "Interval at max should remain at max after resume") + + manager.stop() + } + + func testResumeDevicePollingPreservesStartTime() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + let originalStartTime = manager.pollingStartTime + XCTAssertGreaterThan(originalStartTime, 0) + + manager.resumeDevicePolling() + + XCTAssertEqual( + manager.pollingStartTime, originalStartTime, + "resumeDevicePolling must not reset pollingStartTime — timeout calculation depends on it") + + manager.stop() + } + + func testResumeDevicePollingRestoresPollingFlag() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + // Simulate what pollForDevices does before a connection attempt: + // it calls cancelPollingTimer() which doesn't change isPollingForDevices, + // but if the connection path changes isPollingForDevices to false somehow, + // resumeDevicePolling must restore it. + manager.resumeDevicePolling() + + XCTAssertTrue( + manager.isPollingForDevices, + "resumeDevicePolling must set isPollingForDevices to true") + + manager.stop() + } + + func testBackoffSequenceMatchesExpectedProgression() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + + // Expected sequence: 3 → 6 → 12 → 24 → 30 → 30 (capped) + let expected: [TimeInterval] = [3.0, 6.0, 12.0, 24.0, 30.0, 30.0] + + XCTAssertEqual(manager.currentPollingInterval, expected[0], "Initial interval") + + for i in 1.. Date: Sun, 15 Feb 2026 18:17:26 -0800 Subject: [PATCH 4/4] Fix: Implement attemptDeviceConnection method and add corresponding unit tests for connection logic --- MiddleDrag/Managers/MultitouchManager.swift | 10 +- .../MultitouchManagerTests.swift | 290 +++++++++--------- 2 files changed, 160 insertions(+), 140 deletions(-) diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 61b9a92..fbade63 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -471,7 +471,15 @@ public final class MultitouchManager: @unchecked Sendable { // resumeDevicePolling() needs the current interval and start time intact. cancelPollingTimer() - // Attempt full connection + attemptDeviceConnection() + } + + /// Attempt to connect to a detected multitouch device. + /// Called by pollForDevices() after MTDeviceCreateList confirms a device exists. + /// On failure, resumes polling with backoff. On success, transitions to monitoring. + /// Internal access for testability — allows tests to exercise connection logic + /// without depending on real hardware via MTDeviceCreateList. + internal func attemptDeviceConnection() { applyConfiguration() let eventTapSuccess = eventTapSetupFactory() diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 7d60064..cf718e4 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -628,122 +628,150 @@ final class MultitouchManagerTests: XCTestCase { manager.stop() } - // MARK: - pollForDevices Tests + // MARK: - attemptDeviceConnection Tests - func testPollForDevicesGuardStopsWhenNotPolling() { + func testAttemptDeviceConnectionEventTapFailureResumesPolling() { let mockDevice = unsafe MockDeviceMonitor() unsafe mockDevice.startShouldSucceed = false let manager = MultitouchManager( - deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + deviceProviderFactory: { unsafe mockDevice }, + eventTapSetup: { false } // Event tap always fails + ) - // Don't start polling — isPollingForDevices is false - XCTAssertFalse(manager.isPollingForDevices) + // Enter polling state manually (start() would fail event tap too, + // so set up state directly) + manager.start() + // start() itself fails the event tap and returns early without polling. + // Use a two-phase factory instead: + manager.stop() - // Calling pollForDevices when not polling should be a no-op - manager.pollForDevices() + var eventTapShouldSucceed = true + let manager2 = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, + eventTapSetup: { eventTapShouldSucceed } + ) - XCTAssertFalse(manager.isPollingForDevices) - XCTAssertFalse(manager.isMonitoring) + // First start succeeds event tap but fails device → enters polling + manager2.start() + XCTAssertTrue(manager2.isPollingForDevices) + let intervalBefore = manager2.currentPollingInterval + let startTimeBefore = manager2.pollingStartTime + + // Now make event tap fail for the connection attempt + eventTapShouldSucceed = false + manager2.attemptDeviceConnection() + + // Should resume polling with backoff, preserving start time + XCTAssertTrue(manager2.isPollingForDevices) + XCTAssertFalse(manager2.isMonitoring) + XCTAssertGreaterThan( + manager2.currentPollingInterval, intervalBefore, + "Interval should increase after failed connection") + XCTAssertEqual( + manager2.pollingStartTime, startTimeBefore, + "Start time must be preserved for timeout calculation") + + manager2.stop() } - func testPollForDevicesTimesOutAfterMaxDuration() { - let mockDevice = unsafe MockDeviceMonitor() - unsafe mockDevice.startShouldSucceed = false + func testAttemptDeviceConnectionDeviceMonitorFailureResumesPolling() { + var deviceStartShouldSucceed = false let manager = MultitouchManager( - deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + deviceProviderFactory: { + let monitor = unsafe MockDeviceMonitor() + unsafe monitor.startShouldSucceed = deviceStartShouldSucceed + return unsafe monitor + }, + eventTapSetup: { true } + ) + // Enter polling: device fails on start() manager.start() XCTAssertTrue(manager.isPollingForDevices) + XCTAssertFalse(manager.isMonitoring) + let intervalBefore = manager.currentPollingInterval + let startTimeBefore = manager.pollingStartTime - // Push pollingStartTime far enough into the past to exceed maxPollingDuration - manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration - 1.0 - - manager.pollForDevices() + // attemptDeviceConnection: event tap succeeds, but device monitor still fails + manager.attemptDeviceConnection() - XCTAssertFalse( - manager.isPollingForDevices, - "Polling should stop after exceeding maxPollingDuration") - XCTAssertEqual( - manager.currentPollingInterval, 0, - "Interval should be reset after timeout") + XCTAssertTrue(manager.isPollingForDevices, "Should resume polling after device monitor failure") + XCTAssertFalse(manager.isMonitoring) + XCTAssertFalse(manager.isEnabled) + XCTAssertGreaterThan( + manager.currentPollingInterval, intervalBefore, + "Interval should increase via backoff") XCTAssertEqual( - manager.pollingStartTime, 0, - "Start time should be reset after timeout") + manager.pollingStartTime, startTimeBefore, + "Start time must be preserved") + + manager.stop() } - func testPollForDevicesTimeoutPostsNotification() { - let mockDevice = unsafe MockDeviceMonitor() - unsafe mockDevice.startShouldSucceed = false + func testAttemptDeviceConnectionSuccessTransitionsToMonitoring() { + var deviceStartShouldSucceed = false let manager = MultitouchManager( - deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + deviceProviderFactory: { + let monitor = unsafe MockDeviceMonitor() + unsafe monitor.startShouldSucceed = deviceStartShouldSucceed + return unsafe monitor + }, + eventTapSetup: { true } + ) + // Enter polling state manager.start() + XCTAssertTrue(manager.isPollingForDevices) + XCTAssertFalse(manager.isMonitoring) - let notificationExpectation = XCTNSNotificationExpectation( - name: .middleDragPollingTimedOut, - object: nil - ) + // Now make device available + deviceStartShouldSucceed = true + manager.attemptDeviceConnection() - // Force timeout - manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration - 1.0 - manager.pollForDevices() + XCTAssertTrue(manager.isMonitoring, "Should be monitoring after successful connection") + XCTAssertTrue(manager.isEnabled) + XCTAssertFalse(manager.isPollingForDevices, "Polling should stop after success") + XCTAssertEqual(manager.currentPollingInterval, 0, "Interval should be reset") + XCTAssertEqual(manager.pollingStartTime, 0, "Start time should be reset") - wait(for: [notificationExpectation], timeout: 1.0) + manager.stop() } - func testPollForDevicesEventTapFailureResumesPolling() { - let mockDevice = unsafe MockDeviceMonitor() - unsafe mockDevice.startShouldSucceed = false - - // Event tap succeeds for initial start() but fails during pollForDevices - var eventTapCallCount = 0 + func testAttemptDeviceConnectionSuccessPostsNotification() { + var deviceStartShouldSucceed = false let manager = MultitouchManager( - deviceProviderFactory: { unsafe mockDevice }, - eventTapSetup: { - eventTapCallCount += 1 - // First call succeeds (for start()), subsequent calls fail - return eventTapCallCount <= 1 - } + deviceProviderFactory: { + let monitor = unsafe MockDeviceMonitor() + unsafe monitor.startShouldSucceed = deviceStartShouldSucceed + return unsafe monitor + }, + eventTapSetup: { true } ) manager.start() XCTAssertTrue(manager.isPollingForDevices) - let intervalBeforePoll = manager.currentPollingInterval - let startTimeBeforePoll = manager.pollingStartTime - - // pollForDevices will: - // 1. Pass the guard (isPollingForDevices = true) - // 2. Pass the timeout check (just started) - // 3. Either find or not find devices via MTDeviceCreateList - // - If no devices: backs off (different path than event tap failure) - // - If devices found: tries event tap → fails → resumeDevicePolling - // We can't control MTDeviceCreateList, but we CAN verify the state is consistent - manager.pollForDevices() - // Regardless of which path was taken, polling should still be active - XCTAssertTrue( - manager.isPollingForDevices, - "Polling should continue after poll attempt") - // Start time must be preserved for timeout calculation - XCTAssertEqual( - manager.pollingStartTime, startTimeBeforePoll, - "Start time must be preserved across poll attempts") - // Interval should have increased (backoff) - XCTAssertGreaterThanOrEqual( - manager.currentPollingInterval, intervalBeforePoll, - "Interval should not decrease after a poll attempt") + let notificationExpectation = XCTNSNotificationExpectation( + name: .middleDragDeviceConnected, + object: nil + ) + + deviceStartShouldSucceed = true + manager.attemptDeviceConnection() + + wait(for: [notificationExpectation], timeout: 1.0) manager.stop() } - func testPollForDevicesDeviceMonitorFailureResumesPolling() { - // Device monitor always fails to start, even when devices are found - var deviceStartCallCount = 0 + func testAttemptDeviceConnectionDeviceMonitorFailureCallsStop() { + var deviceStopCount = 0 let manager = MultitouchManager( deviceProviderFactory: { - deviceStartCallCount += 1 let monitor = unsafe MockDeviceMonitor() unsafe monitor.startShouldSucceed = false + // Track stop calls via the mock + deviceStopCount += 1 // Count factory calls; stop tracked via mock return unsafe monitor }, eventTapSetup: { true } @@ -751,21 +779,32 @@ final class MultitouchManagerTests: XCTestCase { manager.start() XCTAssertTrue(manager.isPollingForDevices) - let startTimeBeforePoll = manager.pollingStartTime - manager.pollForDevices() + // Connection attempt: event tap succeeds, device monitor fails + // The code should call deviceMonitor?.stop() before resuming polling + manager.attemptDeviceConnection() - // Polling should still be active regardless of path taken - XCTAssertTrue(manager.isPollingForDevices) XCTAssertFalse(manager.isMonitoring) - XCTAssertEqual( - manager.pollingStartTime, startTimeBeforePoll, - "Start time must be preserved through failed connection attempts") + XCTAssertTrue(manager.isPollingForDevices) manager.stop() } - func testPollForDevicesDoesNotTimeOutBeforeMaxDuration() { + // MARK: - pollForDevices Tests + + func testPollForDevicesGuardStopsWhenNotPolling() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + XCTAssertFalse(manager.isPollingForDevices) + manager.pollForDevices() + XCTAssertFalse(manager.isPollingForDevices) + XCTAssertFalse(manager.isMonitoring) + } + + func testPollForDevicesTimesOutAfterMaxDuration() { let mockDevice = unsafe MockDeviceMonitor() unsafe mockDevice.startShouldSucceed = false let manager = MultitouchManager( @@ -774,67 +813,46 @@ final class MultitouchManagerTests: XCTestCase { manager.start() XCTAssertTrue(manager.isPollingForDevices) - // Set start time to just under the max duration - manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration + 10.0 - + manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration - 1.0 manager.pollForDevices() - // Should NOT have timed out — still within the window - XCTAssertTrue( - manager.isPollingForDevices, - "Polling should continue when within maxPollingDuration") - - manager.stop() + XCTAssertFalse(manager.isPollingForDevices) + XCTAssertEqual(manager.currentPollingInterval, 0) + XCTAssertEqual(manager.pollingStartTime, 0) } - func testPollForDevicesSuccessPostsDeviceConnectedNotification() { - // This test verifies the success path: when a device is found and monitoring starts, - // the .middleDragDeviceConnected notification should be posted. - // We use a factory that fails first (to enter polling) then succeeds. - var shouldSucceed = false + func testPollForDevicesTimeoutPostsNotification() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false let manager = MultitouchManager( - deviceProviderFactory: { - let monitor = unsafe MockDeviceMonitor() - unsafe monitor.startShouldSucceed = shouldSucceed - return unsafe monitor - }, - eventTapSetup: { true } - ) + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) - // Start with failure to enter polling state manager.start() - XCTAssertTrue(manager.isPollingForDevices) - XCTAssertFalse(manager.isMonitoring) - - // Now make devices "available" for the next poll attempt. - // Note: pollForDevices checks MTDeviceCreateList first. On machines WITH - // a trackpad, this returns real devices and the test exercises the full - // success path. On machines WITHOUT a trackpad, MTDeviceCreateList returns - // empty and we hit the backoff path instead — the notification won't fire. - shouldSucceed = true let notificationExpectation = XCTNSNotificationExpectation( - name: .middleDragDeviceConnected, + name: .middleDragPollingTimedOut, object: nil ) - // Make this optional — it only fires if the test machine has real hardware - notificationExpectation.isInverted = false + manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration - 1.0 manager.pollForDevices() - // On machines with a trackpad: monitoring starts, notification fires - // On machines without: stays in polling, notification doesn't fire - if manager.isMonitoring { - wait(for: [notificationExpectation], timeout: 1.0) - XCTAssertTrue(manager.isEnabled) - XCTAssertFalse(manager.isPollingForDevices) - XCTAssertEqual(manager.currentPollingInterval, 0, "Success should reset interval") - XCTAssertEqual(manager.pollingStartTime, 0, "Success should reset start time") - } else { - // No hardware — just verify polling continues - XCTAssertTrue(manager.isPollingForDevices) - } + wait(for: [notificationExpectation], timeout: 1.0) + } + + func testPollForDevicesDoesNotTimeOutBeforeMaxDuration() { + let mockDevice = unsafe MockDeviceMonitor() + unsafe mockDevice.startShouldSucceed = false + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + manager.start() + XCTAssertTrue(manager.isPollingForDevices) + + manager.pollingStartTime = CACurrentMediaTime() - MultitouchManager.maxPollingDuration + 10.0 + manager.pollForDevices() + + XCTAssertTrue(manager.isPollingForDevices) manager.stop() } @@ -845,27 +863,21 @@ final class MultitouchManagerTests: XCTestCase { deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) manager.start() - XCTAssertTrue(manager.isPollingForDevices) - // Simulate multiple poll cycles — each should increase the interval var previousInterval = manager.currentPollingInterval for _ in 0..<10 { manager.pollForDevices() XCTAssertGreaterThanOrEqual( - manager.currentPollingInterval, previousInterval, - "Interval must never decrease across poll cycles") + manager.currentPollingInterval, previousInterval) XCTAssertLessThanOrEqual( manager.currentPollingInterval, - MultitouchManager.maxDevicePollingInterval, - "Interval must never exceed max") + MultitouchManager.maxDevicePollingInterval) previousInterval = manager.currentPollingInterval } - // After enough cycles, should be at max XCTAssertEqual( manager.currentPollingInterval, - MultitouchManager.maxDevicePollingInterval, - "After many cycles, interval should be at max") + MultitouchManager.maxDevicePollingInterval) manager.stop() }