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..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(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) + // Pause the timer but preserve backoff state — if connection fails, + // resumeDevicePolling() needs the current interval and start time intact. + cancelPollingTimer() + + 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() + + 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 + 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 + NotificationCenter.default.post(name: .middleDragDeviceConnected, object: nil) + } + /// Internal stop without removing sleep/wake observers private func internalStop() { mouseGenerator.cancelDrag() @@ -384,6 +574,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..cf718e4 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,542 @@ 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") + } + + 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: - 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.. 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") }